Compare commits
3 Commits
main
..
7d8dd6ea6f
| Author | SHA1 | Date | |
|---|---|---|---|
| 7d8dd6ea6f | |||
| 5e22f1054b | |||
| 46aa59e877 |
@@ -62,7 +62,7 @@ validate run-validation: build clean-disk ## Start steamcache2 on :80 with small
|
|||||||
fi; \
|
fi; \
|
||||||
exec "$$BINARY" --config docs/examples/validate-config.yaml --log-level info
|
exec "$$BINARY" --config docs/examples/validate-config.yaml --log-level info
|
||||||
|
|
||||||
validate-check: ## Curl local /metrics (full dump + hit/miss + upstream/write/rate fields), /lancache-heartbeat, and non-Steam Host reject probe (empty upstream; default :80)
|
validate-check: ## Curl local /metrics (full dump + hit/miss + upstream/write/rate fields) and /lancache-heartbeat (default :80)
|
||||||
@echo "=== http://localhost/metrics ==="
|
@echo "=== http://localhost/metrics ==="
|
||||||
@metrics=$$(curl -sf --max-time 5 http://localhost/metrics) || { \
|
@metrics=$$(curl -sf --max-time 5 http://localhost/metrics) || { \
|
||||||
echo "ERROR: could not fetch http://localhost/metrics"; \
|
echo "ERROR: could not fetch http://localhost/metrics"; \
|
||||||
@@ -84,26 +84,7 @@ validate-check: ## Curl local /metrics (full dump + hit/miss + upstream/write/ra
|
|||||||
echo "$$hb" | grep -q '204' && echo "$$hb" | grep -qi 'X-LanCache-Processed-By' || { \
|
echo "$$hb" | grep -q '204' && echo "$$hb" | grep -qi 'X-LanCache-Processed-By' || { \
|
||||||
echo "ERROR: expected HTTP 204 and X-LanCache-Processed-By on /lancache-heartbeat"; \
|
echo "ERROR: expected HTTP 204 and X-LanCache-Processed-By on /lancache-heartbeat"; \
|
||||||
exit 1; \
|
exit 1; \
|
||||||
}; \
|
}
|
||||||
echo ""; \
|
|
||||||
echo "=== http://localhost/depot/allowlist-probe/chunk (Host: evil.example, Steam UA; expect 400 reject) ==="; \
|
|
||||||
allowlist_body=$$(mktemp); \
|
|
||||||
allowlist_code=$$(curl -s --max-time 5 -o "$$allowlist_body" -w '%{http_code}' -H 'Host: evil.example' -H 'User-Agent: Valve/Steam HTTP Client 1.0' http://localhost/depot/allowlist-probe/chunk) || { \
|
|
||||||
rm -f "$$allowlist_body"; \
|
|
||||||
echo "ERROR: could not probe http://localhost/depot/allowlist-probe/chunk"; \
|
|
||||||
echo "Is steamcache2 running on the default listen address :80?"; \
|
|
||||||
exit 1; \
|
|
||||||
}; \
|
|
||||||
printf 'HTTP %s\n' "$$allowlist_code"; \
|
|
||||||
printf '%s\n' "$$(cat "$$allowlist_body")"; \
|
|
||||||
if [ "$$allowlist_code" != "400" ] || ! grep -q 'Invalid URL' "$$allowlist_body"; then \
|
|
||||||
rm -f "$$allowlist_body"; \
|
|
||||||
echo "ERROR: expected HTTP 400 'Invalid URL' rejecting non-Steam Host with empty upstream (got $$allowlist_code)"; \
|
|
||||||
echo "Host allowlist gate regressed: steamcache2 may act as an open LAN reverse proxy."; \
|
|
||||||
exit 1; \
|
|
||||||
fi; \
|
|
||||||
rm -f "$$allowlist_body"; \
|
|
||||||
echo "Host allowlist reject OK (non-Steam Host -> 400)"
|
|
||||||
|
|
||||||
validate-kill: ## Kill leftover steamcache2 processes (safer, checks process name)
|
validate-kill: ## Kill leftover steamcache2 processes (safer, checks process name)
|
||||||
@echo "Looking for steamcache2 processes on common validation ports (80 is primary)..."
|
@echo "Looking for steamcache2 processes on common validation ports (80 is primary)..."
|
||||||
@@ -152,7 +133,7 @@ help: ## Show this help message
|
|||||||
@echo " clean-disk Remove disk cache"
|
@echo " clean-disk Remove disk cache"
|
||||||
@echo " bench Run low-level VFS microbenchmarks"
|
@echo " bench Run low-level VFS microbenchmarks"
|
||||||
@echo " validate / run-validation Start server on :80 (builds, auto-setcaps fresh binary, then runs as normal user, cleans disk cache first)"
|
@echo " validate / run-validation Start server on :80 (builds, auto-setcaps fresh binary, then runs as normal user, cleans disk cache first)"
|
||||||
@echo " validate-check Curl local /metrics (full dump + hit/miss + upstream/write/rate fields), /lancache-heartbeat, and non-Steam Host reject probe (empty upstream; default :80)"
|
@echo " validate-check Curl local /metrics (full dump + hit/miss + upstream/write/rate fields) and /lancache-heartbeat (default :80)"
|
||||||
@echo " setcap Explicitly set cap on current build (for port 80 use outside validate)"
|
@echo " setcap Explicitly set cap on current build (for port 80 use outside validate)"
|
||||||
@echo " validate-kill Kill leftover steamcache2 processes (safer)"
|
@echo " validate-kill Kill leftover steamcache2 processes (safer)"
|
||||||
@echo " prefill Download latest SteamPrefill into bin/steam-prefill/SteamPrefill (gitignored)"
|
@echo " prefill Download latest SteamPrefill into bin/steam-prefill/SteamPrefill (gitignored)"
|
||||||
|
|||||||
@@ -76,33 +76,24 @@ curl -s http://localhost/metrics
|
|||||||
curl -s -i http://localhost/lancache-heartbeat
|
curl -s -i http://localhost/lancache-heartbeat
|
||||||
```
|
```
|
||||||
|
|
||||||
`make validate-check` prints the full `/metrics` dump, highlights hit/miss plus `upstream_errors` / `cache_write_failures` / `rate_limited`, and curls `/lancache-heartbeat`. It also asserts the empty-upstream Host allowlist: a non-Steam `Host` sent with a Steam `User-Agent` must be rejected with HTTP 400. Read these fields:
|
`make validate-check` prints the full `/metrics` dump, highlights hit/miss plus `upstream_errors` / `cache_write_failures` / `rate_limited`, and curls `/lancache-heartbeat`. Read these fields:
|
||||||
|
|
||||||
| Field | Meaning |
|
| Field | Meaning |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `cache_hits` / `cache_misses` / `hit_rate` | Whether later requests were served from cache |
|
| `cache_hits` / `cache_misses` / `hit_rate` | Whether later requests were served from cache |
|
||||||
| `cache_coalesced` | Waiters on an in-flight identical miss share one upstream fill (`X-LanCache-Status: HIT-COALESCED`) |
|
|
||||||
| `negative_cache_hits` | 404/410 served from a still-valid negative cache entry (also counted in `cache_hits`) |
|
|
||||||
| `range_cache` / `range_upstream` | Range GETs served as 206 from a cached object vs after a full upstream fetch |
|
| `range_cache` / `range_upstream` | Range GETs served as 206 from a cached object vs after a full upstream fetch |
|
||||||
| `memory_cache_hits` / `disk_cache_hits` | Which tier served the hits |
|
| `memory_cache_hits` / `disk_cache_hits` | Which tier served the hits |
|
||||||
| `total_requests` / `errors` | Volume and failures |
|
| `total_requests` / `errors` | Volume and failures |
|
||||||
| `upstream_errors` / `cache_write_failures` / `rate_limited` | Upstream pipe, cache write, and rate-limit pressure (Quick check highlights these next to hit/miss) |
|
| `upstream_errors` / `cache_write_failures` / `rate_limited` | Upstream pipe, cache write, and rate-limit pressure (Quick check highlights these next to hit/miss) |
|
||||||
| `disk_tier_ready` | `0` while disk slow-tier attach pending; `1` when attached, or when no disk configured (N/A — not waiting) |
|
| `disk_tier_ready` | `0` while disk slow-tier attach pending; `1` when attached, or when no disk configured (N/A — not waiting) |
|
||||||
| `memory_cache_size` / `disk_cache_size` | Current cache occupancy per tier (bytes) |
|
|
||||||
| `memory_cache_capacity` / `disk_cache_capacity` | Configured capacity per tier (bytes); `disk_cache_capacity` is `0` when no disk is configured |
|
|
||||||
| `disk_cache_full_ratio` | `disk_cache_size / disk_cache_capacity` in [0,1]; 0 when no disk is configured or capacity is 0. Tells you "95% full" vs "barely filled" without reading the filesystem |
|
|
||||||
| `capacity_pressure_events` | Soft eviction under the memory or disk cap, and/or disk Create/Write/Mkdir hitting ENOSPC (volume full). Distinct from cold-cache misses and from the existing `evictions` counter. Logs `tier` (memory or disk) and `reason` (eviction or enospc). |
|
| `capacity_pressure_events` | Soft eviction under the memory or disk cap, and/or disk Create/Write/Mkdir hitting ENOSPC (volume full). Distinct from cold-cache misses and from the existing `evictions` counter. Logs `tier` (memory or disk) and `reason` (eviction or enospc). |
|
||||||
|
|
||||||
A first pass through new content is mostly misses (`hit_rate` near 0). Repeat the same content and `cache_hits` / `hit_rate` should rise.
|
A first pass through new content is mostly misses (`hit_rate` near 0). Repeat the same content and `cache_hits` / `hit_rate` should rise.
|
||||||
|
|
||||||
Cache entries are keyed by depot object path (not the CDN `Host` header), so when Steam rotates CDN hostnames for the same depot path, hits still climb across the aliases.
|
Cache entries are keyed by depot object path (not the CDN `Host` header), so when Steam rotates CDN hostnames for the same depot path, hits still climb across the aliases.
|
||||||
|
|
||||||
Concurrent identical misses for the same key share one upstream GET: the leader is a `MISS` and waiters are `HIT-COALESCED` (`cache_coalesced`).
|
|
||||||
|
|
||||||
Steam clients lean on Range requests. When an object is already cached, a Range GET is served locally as 206 from that full object (`range_cache`). On a Range miss the cache still fetches and stores the full upstream body, then returns the requested byte range as 206 (`range_upstream`).
|
Steam clients lean on Range requests. When an object is already cached, a Range GET is served locally as 206 from that full object (`range_cache`). On a Range miss the cache still fetches and stores the full upstream body, then returns the requested byte range as 206 (`range_upstream`).
|
||||||
|
|
||||||
Definitive upstream 404/410 (gone depot objects) are stored as a short-TTL negative entry in the **same** cache, under the same depot-path key as a positive object. Repeating the request within `cache.negative_ttl` (default `5m`) is served as 404/410 without re-hitting upstream (`negative_cache_hits`). 5xx is not cached as negative. When the TTL expires the entry is deleted and the next request fetches again.
|
|
||||||
|
|
||||||
To confirm the process is up (HTTP 204 and `X-LanCache-Processed-By: SteamCache2`):
|
To confirm the process is up (HTTP 204 and `X-LanCache-Processed-By: SteamCache2`):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -115,8 +106,6 @@ Heartbeat also returns `X-SteamCache-Disk-Tier: pending|ready|disabled` (`disabl
|
|||||||
|
|
||||||
These are the cache process's own `/metrics` and `/lancache-heartbeat` endpoints. There is no separate metrics daemon.
|
These are the cache process's own `/metrics` and `/lancache-heartbeat` endpoints. There is no separate metrics daemon.
|
||||||
|
|
||||||
`/metrics` is Prometheus text exposition format 0.0.4 (`Content-Type: text/plain; version=0.0.4; charset=utf-8`) so Prometheus and compatible scrapers can pull it. Metric names in the table above are unchanged; each series is preceded by `# HELP` and `# TYPE`.
|
|
||||||
|
|
||||||
If you changed `listen_address`, point curl at that host:port instead. For a full SteamPrefill validation workflow (small caches, coalescing, GC), see [Validating Full Functionality](#validating-full-functionality-with-external-tools).
|
If you changed `listen_address`, point curl at that host:port instead. For a full SteamPrefill validation workflow (small caches, coalescing, GC), see [Validating Full Functionality](#validating-full-functionality-with-external-tools).
|
||||||
|
|
||||||
### Development Workflow
|
### Development Workflow
|
||||||
@@ -184,7 +173,7 @@ curl -s http://localhost/metrics
|
|||||||
curl -s -i http://localhost/lancache-heartbeat
|
curl -s -i http://localhost/lancache-heartbeat
|
||||||
```
|
```
|
||||||
|
|
||||||
`make validate-check` prints the full `/metrics` dump, highlights hit/miss fields, and curls `/lancache-heartbeat`. It also asserts a non-Steam Host is rejected with HTTP 400 when upstream is empty. Look for:
|
`make validate-check` prints the full `/metrics` dump, highlights hit/miss fields, and curls `/lancache-heartbeat`. Look for:
|
||||||
- High cache hit rate after the warmup pass (`cache_hits`, `hit_rate`, plus `memory_cache_hits` / `disk_cache_hits`)
|
- High cache hit rate after the warmup pass (`cache_hits`, `hit_rate`, plus `memory_cache_hits` / `disk_cache_hits`)
|
||||||
- Non-zero `coalesced` and `disk` activity
|
- Non-zero `coalesced` and `disk` activity
|
||||||
- Zero unexpected `errors`, and quiet `upstream_errors` / `cache_write_failures` / `rate_limited`
|
- Zero unexpected `errors`, and quiet `upstream_errors` / `cache_write_failures` / `rate_limited`
|
||||||
@@ -236,10 +225,6 @@ While most configuration is done via the YAML file, some runtime options are sti
|
|||||||
./steamcache2 --max-concurrent-requests 8
|
./steamcache2 --max-concurrent-requests 8
|
||||||
./steamcache2 --max-requests-per-client 4
|
./steamcache2 --max-requests-per-client 4
|
||||||
|
|
||||||
# Table-tier uplink shaping (empty/0 = use config / disabled)
|
|
||||||
./steamcache2 --uplink-bandwidth 10MB
|
|
||||||
./steamcache2 --max-bytes-per-client-per-sec 2500000
|
|
||||||
|
|
||||||
# Show help
|
# Show help
|
||||||
./steamcache2 --help
|
./steamcache2 --help
|
||||||
```
|
```
|
||||||
@@ -256,11 +241,6 @@ listen_address: :80
|
|||||||
max_object_size: "0" # 0=unlimited; set e.g. "256MB" for response size DoS protection
|
max_object_size: "0" # 0=unlimited; set e.g. "256MB" for response size DoS protection
|
||||||
trusted_proxies: [] # empty = safe (ignore XFF for rate limit); set CIDRs for trusted proxies
|
trusted_proxies: [] # empty = safe (ignore XFF for rate limit); set CIDRs for trusted proxies
|
||||||
|
|
||||||
# Table-tier uplink bandwidth shaping (bytes/sec). Empty/0 = disabled (unlimited).
|
|
||||||
# Distinct from max_requests_per_client (concurrency). See "Table-tier uplink fair-share".
|
|
||||||
uplink_bandwidth: "" # e.g. "10MB" = 10e6 bytes/sec shared fairly across active clients
|
|
||||||
max_bytes_per_client_per_sec: 0 # optional absolute per-client cap; 0 = no absolute cap
|
|
||||||
|
|
||||||
# Cache configuration
|
# Cache configuration
|
||||||
cache:
|
cache:
|
||||||
# Memory cache settings
|
# Memory cache settings
|
||||||
@@ -279,10 +259,6 @@ cache:
|
|||||||
# Garbage collection algorithm
|
# Garbage collection algorithm
|
||||||
gc_algorithm: hybrid
|
gc_algorithm: hybrid
|
||||||
|
|
||||||
# Short TTL for cached 404/410 (gone depot objects). Default 5m.
|
|
||||||
# Same VFS cache and depot-path key as positive objects. Does not cache 5xx.
|
|
||||||
negative_ttl: 5m
|
|
||||||
|
|
||||||
# Upstream server configuration
|
# Upstream server configuration
|
||||||
# Leave empty to fetch from the request Host (Steam CDN names only).
|
# Leave empty to fetch from the request Host (Steam CDN names only).
|
||||||
# Set only when chaining caches (table RAM cache -> room disk cache).
|
# Set only when chaining caches (table RAM cache -> room disk cache).
|
||||||
@@ -310,9 +286,9 @@ See `config.Validate()` and `steamcache.New` error paths. This ensures the LAN a
|
|||||||
- These + the startup validation make steamcache2 safe-by-default for LAN exposure.
|
- These + the startup validation make steamcache2 safe-by-default for LAN exposure.
|
||||||
|
|
||||||
#### Migration / Breaking Changes
|
#### Migration / Breaking Changes
|
||||||
- `New()` public signature gained trailing params (`maxObjectSize`, `trustedProxies`, `negativeTTL`). Direct callers (rare; most use config or NewWithOptions) must update. Empty `negativeTTL` means 5m.
|
- `New()` public signature gained 2 required trailing params (`maxObjectSize`, `trustedProxies`). Direct callers (rare; most use config or NewWithOptions) must update.
|
||||||
- Recommended: migrate to `NewWithOptions(Options{...})` (non-breaking) or rely on YAML config + cmd/root.go.
|
- Recommended: migrate to `NewWithOptions(Options{...})` (non-breaking) or rely on YAML config + cmd/root.go.
|
||||||
- No behavior change for existing configs (defaults preserve prior semantics; `cache.negative_ttl` defaults to 5m).
|
- No behavior change for existing configs (defaults preserve prior semantics).
|
||||||
|
|
||||||
#### Large Cache Initialization (async DiskFS population)
|
#### Large Cache Initialization (async DiskFS population)
|
||||||
- `disk.New(root, capacity, evictFn)` signature changed (now takes evict func from `gc.GetGCAlgorithm`, returns error for ctor hygiene). Callers updated internally; direct vfs/disk users must pass the evict (or nil for no startup guard).
|
- `disk.New(root, capacity, evictFn)` signature changed (now takes evict func from `gc.GetGCAlgorithm`, returns error for ctor hygiene). Callers updated internally; direct vfs/disk users must pass the evict (or nil for no startup guard).
|
||||||
@@ -323,7 +299,6 @@ See `config.Validate()` and `steamcache.New` error paths. This ensures the LAN a
|
|||||||
- Godoc on `disk.New` and `DiskFS.Size` expanded with the barrier/attach behavior.
|
- Godoc on `disk.New` and `DiskFS.Size` expanded with the barrier/attach behavior.
|
||||||
- Startup logs: Info "Disk slow tier attach pending..." then later "Disk slow tier attached (...)" for disk-only and mixed modes.
|
- Startup logs: Info "Disk slow tier attach pending..." then later "Disk slow tier attached (...)" for disk-only and mixed modes.
|
||||||
- `/metrics` exposes `disk_tier_ready` 0/1 and stays responsive during attach (GetMetrics does not block on Size while pending).
|
- `/metrics` exposes `disk_tier_ready` 0/1 and stays responsive during attach (GetMetrics does not block on Size while pending).
|
||||||
- `/metrics` tier occupancy: `memory_cache_size` / `disk_cache_size` (bytes in use) next to `memory_cache_capacity` / `disk_cache_capacity` (configured capacity; `disk_cache_capacity` is 0 when no disk is configured), plus `disk_cache_full_ratio` (size/capacity in [0,1]). Capacity is a config read, so it is reported even while the disk attach is pending (size stays 0 until attach).
|
|
||||||
- `/lancache-heartbeat` header `X-SteamCache-Disk-Tier` mirrors that state.
|
- `/lancache-heartbeat` header `X-SteamCache-Disk-Tier` mirrors that state.
|
||||||
- `/metrics` `capacity_pressure_events` counts times the cache dropped data under capacity pressure (soft eviction at the memory or disk cap, or disk Create/Write/Mkdir returning ENOSPC). Logs include `tier=memory|disk` and `reason=eviction|enospc` so operators can grep and tell this apart from a cold cache. The existing `evictions` counter is unchanged.
|
- `/metrics` `capacity_pressure_events` counts times the cache dropped data under capacity pressure (soft eviction at the memory or disk cap, or disk Create/Write/Mkdir returning ENOSPC). Logs include `tier=memory|disk` and `reason=eviction|enospc` so operators can grep and tell this apart from a cold cache. The existing `evictions` counter is unchanged.
|
||||||
|
|
||||||
|
|||||||
-13
@@ -22,8 +22,6 @@ var (
|
|||||||
|
|
||||||
maxConcurrentRequests int64
|
maxConcurrentRequests int64
|
||||||
maxRequestsPerClient int64
|
maxRequestsPerClient int64
|
||||||
uplinkBandwidth string
|
|
||||||
maxBytesPerClientPerSec int64
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var rootCmd = &cobra.Command{
|
var rootCmd = &cobra.Command{
|
||||||
@@ -109,12 +107,6 @@ var rootCmd = &cobra.Command{
|
|||||||
if maxRequestsPerClient > 0 {
|
if maxRequestsPerClient > 0 {
|
||||||
finalMaxRequestsPerClient = maxRequestsPerClient
|
finalMaxRequestsPerClient = maxRequestsPerClient
|
||||||
}
|
}
|
||||||
if uplinkBandwidth != "" {
|
|
||||||
cfg.UplinkBandwidth = uplinkBandwidth
|
|
||||||
}
|
|
||||||
if maxBytesPerClientPerSec > 0 {
|
|
||||||
cfg.MaxBytesPerClientPerSec = maxBytesPerClientPerSec
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate after loading and applying CLI overrides (fail fast, do not create default on validate error)
|
// Validate after loading and applying CLI overrides (fail fast, do not create default on validate error)
|
||||||
if err := cfg.Validate(); err != nil {
|
if err := cfg.Validate(); err != nil {
|
||||||
@@ -137,9 +129,6 @@ var rootCmd = &cobra.Command{
|
|||||||
finalMaxRequestsPerClient,
|
finalMaxRequestsPerClient,
|
||||||
cfg.MaxObjectSize,
|
cfg.MaxObjectSize,
|
||||||
cfg.TrustedProxies,
|
cfg.TrustedProxies,
|
||||||
cfg.Cache.NegativeTTL,
|
|
||||||
cfg.UplinkBandwidth,
|
|
||||||
cfg.MaxBytesPerClientPerSec,
|
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Logger.Error().
|
logger.Logger.Error().
|
||||||
@@ -180,6 +169,4 @@ func init() {
|
|||||||
|
|
||||||
rootCmd.Flags().Int64Var(&maxConcurrentRequests, "max-concurrent-requests", 0, "Maximum concurrent requests (0 = use config file value)")
|
rootCmd.Flags().Int64Var(&maxConcurrentRequests, "max-concurrent-requests", 0, "Maximum concurrent requests (0 = use config file value)")
|
||||||
rootCmd.Flags().Int64Var(&maxRequestsPerClient, "max-requests-per-client", 0, "Maximum concurrent requests per client IP (0 = use config file value)")
|
rootCmd.Flags().Int64Var(&maxRequestsPerClient, "max-requests-per-client", 0, "Maximum concurrent requests per client IP (0 = use config file value)")
|
||||||
rootCmd.Flags().StringVar(&uplinkBandwidth, "uplink-bandwidth", "", "Table uplink bandwidth bytes/sec human size e.g. 10MB (empty = use config; 0 disables)")
|
|
||||||
rootCmd.Flags().Int64Var(&maxBytesPerClientPerSec, "max-bytes-per-client-per-sec", 0, "Absolute per-client bytes/sec cap (0 = use config file value)")
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import (
|
|||||||
"net"
|
"net"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/docker/go-units"
|
"github.com/docker/go-units"
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
@@ -19,11 +18,6 @@ type Config struct {
|
|||||||
MaxConcurrentRequests int64 `yaml:"max_concurrent_requests" default:"200"`
|
MaxConcurrentRequests int64 `yaml:"max_concurrent_requests" default:"200"`
|
||||||
MaxRequestsPerClient int64 `yaml:"max_requests_per_client" default:"5"`
|
MaxRequestsPerClient int64 `yaml:"max_requests_per_client" default:"5"`
|
||||||
|
|
||||||
// Table-tier uplink bandwidth shaping (bytes/sec). Distinct from MaxRequestsPerClient.
|
|
||||||
// Empty/"0" uplink and 0 max_bytes_per_client_per_sec = disabled (current unlimited behavior).
|
|
||||||
UplinkBandwidth string `yaml:"uplink_bandwidth"` // e.g. "10MB" via go-units = bytes/sec
|
|
||||||
MaxBytesPerClientPerSec int64 `yaml:"max_bytes_per_client_per_sec"` // absolute per-client cap; 0 = none
|
|
||||||
|
|
||||||
// Hardening limits (security/correctness)
|
// Hardening limits (security/correctness)
|
||||||
MaxObjectSize string `yaml:"max_object_size" default:"0"` // 0=unlimited; e.g. "256MB" protects against OOM from huge/malicious upstream responses
|
MaxObjectSize string `yaml:"max_object_size" default:"0"` // 0=unlimited; e.g. "256MB" protects against OOM from huge/malicious upstream responses
|
||||||
TrustedProxies []string `yaml:"trusted_proxies"` // CIDR list; empty=never trust X-Forwarded-For (safe default). See README security notes.
|
TrustedProxies []string `yaml:"trusted_proxies"` // CIDR list; empty=never trust X-Forwarded-For (safe default). See README security notes.
|
||||||
@@ -41,11 +35,6 @@ type CacheConfig struct {
|
|||||||
|
|
||||||
// Disk cache settings
|
// Disk cache settings
|
||||||
Disk DiskConfig `yaml:"disk"`
|
Disk DiskConfig `yaml:"disk"`
|
||||||
|
|
||||||
// NegativeTTL is a Go duration string for cached 404/410 depot objects
|
|
||||||
// (same VFS key as a positive hit). Empty defaults to 5m. "0" disables
|
|
||||||
// storing negatives (the client still receives the upstream 404/410).
|
|
||||||
NegativeTTL string `yaml:"negative_ttl"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type MemoryConfig struct {
|
type MemoryConfig struct {
|
||||||
@@ -111,9 +100,6 @@ func LoadConfig(configPath string) (*Config, error) {
|
|||||||
if config.Cache.Disk.GCAlgorithm == "" {
|
if config.Cache.Disk.GCAlgorithm == "" {
|
||||||
config.Cache.Disk.GCAlgorithm = "lru"
|
config.Cache.Disk.GCAlgorithm = "lru"
|
||||||
}
|
}
|
||||||
if config.Cache.NegativeTTL == "" {
|
|
||||||
config.Cache.NegativeTTL = "5m"
|
|
||||||
}
|
|
||||||
|
|
||||||
return &config, nil
|
return &config, nil
|
||||||
}
|
}
|
||||||
@@ -140,7 +126,6 @@ func SaveDefaultConfig(configPath string) error {
|
|||||||
Path: "./disk",
|
Path: "./disk",
|
||||||
GCAlgorithm: "lru", // Better for gaming patterns (keeps recently played games)
|
GCAlgorithm: "lru", // Better for gaming patterns (keeps recently played games)
|
||||||
},
|
},
|
||||||
NegativeTTL: "5m",
|
|
||||||
},
|
},
|
||||||
Upstream: "",
|
Upstream: "",
|
||||||
}
|
}
|
||||||
@@ -177,7 +162,6 @@ func GetDefaultConfig() Config {
|
|||||||
Path: "./disk",
|
Path: "./disk",
|
||||||
GCAlgorithm: "lru",
|
GCAlgorithm: "lru",
|
||||||
},
|
},
|
||||||
NegativeTTL: "5m",
|
|
||||||
},
|
},
|
||||||
Upstream: "",
|
Upstream: "",
|
||||||
}
|
}
|
||||||
@@ -191,14 +175,6 @@ func (c Config) Validate() error {
|
|||||||
if c.MaxRequestsPerClient < 0 {
|
if c.MaxRequestsPerClient < 0 {
|
||||||
return fmt.Errorf("negative per-client limit not allowed")
|
return fmt.Errorf("negative per-client limit not allowed")
|
||||||
}
|
}
|
||||||
if c.MaxBytesPerClientPerSec < 0 {
|
|
||||||
return fmt.Errorf("negative max_bytes_per_client_per_sec not allowed")
|
|
||||||
}
|
|
||||||
if c.UplinkBandwidth != "" && c.UplinkBandwidth != "0" {
|
|
||||||
if _, err := units.FromHumanSize(c.UplinkBandwidth); err != nil {
|
|
||||||
return fmt.Errorf("invalid uplink_bandwidth: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if c.Cache.Memory.GCAlgorithm != "" {
|
if c.Cache.Memory.GCAlgorithm != "" {
|
||||||
switch c.Cache.Memory.GCAlgorithm {
|
switch c.Cache.Memory.GCAlgorithm {
|
||||||
@@ -212,16 +188,6 @@ func (c Config) Validate() error {
|
|||||||
return fmt.Errorf("disk cache enabled but no path specified")
|
return fmt.Errorf("disk cache enabled but no path specified")
|
||||||
}
|
}
|
||||||
|
|
||||||
if c.Cache.NegativeTTL != "" {
|
|
||||||
d, err := time.ParseDuration(c.Cache.NegativeTTL)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("invalid cache.negative_ttl: %w", err)
|
|
||||||
}
|
|
||||||
if d < 0 {
|
|
||||||
return fmt.Errorf("invalid cache.negative_ttl: negative duration")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Light validation for security/resource fields (mirrors existing GC + path checks; fails fast before New)
|
// Light validation for security/resource fields (mirrors existing GC + path checks; fails fast before New)
|
||||||
if c.MaxObjectSize != "" && c.MaxObjectSize != "0" {
|
if c.MaxObjectSize != "" && c.MaxObjectSize != "0" {
|
||||||
if _, err := units.FromHumanSize(c.MaxObjectSize); err != nil {
|
if _, err := units.FromHumanSize(c.MaxObjectSize); err != nil {
|
||||||
|
|||||||
@@ -156,44 +156,6 @@ func TestValidate(t *testing.T) {
|
|||||||
}(),
|
}(),
|
||||||
wantErr: false,
|
wantErr: false,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: "valid negative_ttl duration",
|
|
||||||
cfg: func() Config {
|
|
||||||
c := GetDefaultConfig()
|
|
||||||
c.Cache.NegativeTTL = "1m"
|
|
||||||
return c
|
|
||||||
}(),
|
|
||||||
wantErr: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "empty negative_ttl ok",
|
|
||||||
cfg: func() Config {
|
|
||||||
c := GetDefaultConfig()
|
|
||||||
c.Cache.NegativeTTL = ""
|
|
||||||
return c
|
|
||||||
}(),
|
|
||||||
wantErr: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "invalid negative_ttl",
|
|
||||||
cfg: func() Config {
|
|
||||||
c := GetDefaultConfig()
|
|
||||||
c.Cache.NegativeTTL = "not-a-duration"
|
|
||||||
return c
|
|
||||||
}(),
|
|
||||||
wantErr: true,
|
|
||||||
errSub: "invalid cache.negative_ttl",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "negative duration negative_ttl",
|
|
||||||
cfg: func() Config {
|
|
||||||
c := GetDefaultConfig()
|
|
||||||
c.Cache.NegativeTTL = "-1s"
|
|
||||||
return c
|
|
||||||
}(),
|
|
||||||
wantErr: true,
|
|
||||||
errSub: "invalid cache.negative_ttl",
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
@@ -211,71 +173,3 @@ func TestValidate(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestValidateUplinkBandwidth(t *testing.T) {
|
|
||||||
cases := []struct {
|
|
||||||
name string
|
|
||||||
mutate func(*Config)
|
|
||||||
wantErr bool
|
|
||||||
errSub string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "empty uplink ok",
|
|
||||||
mutate: func(c *Config) {
|
|
||||||
c.UplinkBandwidth = ""
|
|
||||||
c.MaxBytesPerClientPerSec = 0
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "zero uplink ok",
|
|
||||||
mutate: func(c *Config) {
|
|
||||||
c.UplinkBandwidth = "0"
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "valid human size",
|
|
||||||
mutate: func(c *Config) {
|
|
||||||
c.UplinkBandwidth = "10MB"
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "invalid uplink",
|
|
||||||
mutate: func(c *Config) {
|
|
||||||
c.UplinkBandwidth = "not-a-size"
|
|
||||||
},
|
|
||||||
wantErr: true,
|
|
||||||
errSub: "uplink_bandwidth",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "negative max bytes",
|
|
||||||
mutate: func(c *Config) {
|
|
||||||
c.MaxBytesPerClientPerSec = -1
|
|
||||||
},
|
|
||||||
wantErr: true,
|
|
||||||
errSub: "max_bytes_per_client_per_sec",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
for _, tt := range cases {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
c := GetDefaultConfig()
|
|
||||||
tt.mutate(&c)
|
|
||||||
err := c.Validate()
|
|
||||||
if tt.wantErr {
|
|
||||||
if err == nil {
|
|
||||||
t.Fatalf("Validate() error = nil, wantErr")
|
|
||||||
}
|
|
||||||
if tt.errSub != "" && !contains(err.Error(), tt.errSub) {
|
|
||||||
t.Fatalf("Validate() error %q does not contain %q", err.Error(), tt.errSub)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Validate() unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func contains(s, sub string) bool {
|
|
||||||
return strings.Contains(s, sub)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -28,7 +28,6 @@
|
|||||||
#
|
#
|
||||||
# After the benchmark run, inspect with:
|
# After the benchmark run, inspect with:
|
||||||
# make validate-check # full /metrics + hit/miss fields + /lancache-heartbeat
|
# make validate-check # full /metrics + hit/miss fields + /lancache-heartbeat
|
||||||
# # also asserts a non-Steam Host is rejected (400) while upstream is empty
|
|
||||||
# # or, manually:
|
# # or, manually:
|
||||||
# curl -s http://localhost/metrics
|
# curl -s http://localhost/metrics
|
||||||
# curl -s -i http://localhost/lancache-heartbeat # GET, not HEAD
|
# curl -s -i http://localhost/lancache-heartbeat # GET, not HEAD
|
||||||
@@ -39,7 +38,6 @@
|
|||||||
listen_address: :80
|
listen_address: :80
|
||||||
|
|
||||||
max_concurrent_requests: 1000
|
max_concurrent_requests: 1000
|
||||||
# uplink_bandwidth / max_bytes_per_client_per_sec default off (unlimited)
|
|
||||||
max_requests_per_client: 10
|
max_requests_per_client: 10
|
||||||
|
|
||||||
max_object_size: "0" # unlimited for validation (real Steam files can be large)
|
max_object_size: "0" # unlimited for validation (real Steam files can be large)
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ require (
|
|||||||
github.com/spf13/cobra v1.8.1
|
github.com/spf13/cobra v1.8.1
|
||||||
golang.org/x/sync v0.16.0
|
golang.org/x/sync v0.16.0
|
||||||
golang.org/x/sys v0.12.0
|
golang.org/x/sys v0.12.0
|
||||||
golang.org/x/time v0.16.0
|
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -27,8 +27,6 @@ golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBc
|
|||||||
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 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/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE=
|
|
||||||
golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s=
|
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/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=
|
||||||
|
|||||||
@@ -1,158 +0,0 @@
|
|||||||
// steamcache/bandwidth.go
|
|
||||||
// Per-client fair-share / absolute bandwidth shaping for table-tier uplink.
|
|
||||||
// Distinct from max_requests_per_client concurrency (semaphores in ratelimit.go).
|
|
||||||
package steamcache
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"net/http"
|
|
||||||
"sync"
|
|
||||||
|
|
||||||
"golang.org/x/time/rate"
|
|
||||||
)
|
|
||||||
|
|
||||||
const bandwidthWriteChunk = 32 * 1024
|
|
||||||
|
|
||||||
// clientBandwidthLimiter fair-shares uplinkBytesPerSec among active clients and/or
|
|
||||||
// applies an absolute per-client bytes/sec cap. Both 0 disables shaping.
|
|
||||||
type clientBandwidthLimiter struct {
|
|
||||||
uplinkBytesPerSec int64
|
|
||||||
absoluteCap int64
|
|
||||||
|
|
||||||
mu sync.Mutex
|
|
||||||
active map[string]int // refcount of in-flight shaped responses per client IP
|
|
||||||
limiters map[string]*rate.Limiter
|
|
||||||
}
|
|
||||||
|
|
||||||
func newClientBandwidthLimiter(uplinkBytesPerSec, absoluteCap int64) *clientBandwidthLimiter {
|
|
||||||
if uplinkBytesPerSec < 0 {
|
|
||||||
uplinkBytesPerSec = 0
|
|
||||||
}
|
|
||||||
if absoluteCap < 0 {
|
|
||||||
absoluteCap = 0
|
|
||||||
}
|
|
||||||
return &clientBandwidthLimiter{
|
|
||||||
uplinkBytesPerSec: uplinkBytesPerSec,
|
|
||||||
absoluteCap: absoluteCap,
|
|
||||||
active: make(map[string]int),
|
|
||||||
limiters: make(map[string]*rate.Limiter),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (b *clientBandwidthLimiter) enabled() bool {
|
|
||||||
return b != nil && (b.uplinkBytesPerSec > 0 || b.absoluteCap > 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
// acquire registers clientIP as actively downloading and returns its limiter
|
|
||||||
// (nil if shaping disabled) plus a release func that must be deferred.
|
|
||||||
func (b *clientBandwidthLimiter) acquire(clientIP string) (*rate.Limiter, func()) {
|
|
||||||
if !b.enabled() {
|
|
||||||
return nil, func() {}
|
|
||||||
}
|
|
||||||
b.mu.Lock()
|
|
||||||
b.active[clientIP]++
|
|
||||||
lim := b.ensureLimiterLocked(clientIP)
|
|
||||||
b.recomputeRatesLocked()
|
|
||||||
b.mu.Unlock()
|
|
||||||
|
|
||||||
var once sync.Once
|
|
||||||
release := func() {
|
|
||||||
once.Do(func() {
|
|
||||||
b.mu.Lock()
|
|
||||||
defer b.mu.Unlock()
|
|
||||||
if n := b.active[clientIP]; n <= 1 {
|
|
||||||
delete(b.active, clientIP)
|
|
||||||
} else {
|
|
||||||
b.active[clientIP] = n - 1
|
|
||||||
}
|
|
||||||
b.recomputeRatesLocked()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return lim, release
|
|
||||||
}
|
|
||||||
|
|
||||||
func (b *clientBandwidthLimiter) ensureLimiterLocked(clientIP string) *rate.Limiter {
|
|
||||||
if lim, ok := b.limiters[clientIP]; ok {
|
|
||||||
return lim
|
|
||||||
}
|
|
||||||
// Start with a placeholder; recomputeRatesLocked sets the real rate.
|
|
||||||
lim := rate.NewLimiter(rate.Limit(1), 1)
|
|
||||||
b.limiters[clientIP] = lim
|
|
||||||
return lim
|
|
||||||
}
|
|
||||||
|
|
||||||
func (b *clientBandwidthLimiter) recomputeRatesLocked() {
|
|
||||||
n := len(b.active)
|
|
||||||
if n == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var fair int64
|
|
||||||
if b.uplinkBytesPerSec > 0 {
|
|
||||||
fair = b.uplinkBytesPerSec / int64(n)
|
|
||||||
if fair < 1 {
|
|
||||||
fair = 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for ip := range b.active {
|
|
||||||
r := fair
|
|
||||||
if b.absoluteCap > 0 {
|
|
||||||
if r == 0 || b.absoluteCap < r {
|
|
||||||
r = b.absoluteCap
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if r < 1 {
|
|
||||||
r = 1
|
|
||||||
}
|
|
||||||
lim := b.ensureLimiterLocked(ip)
|
|
||||||
burst := int(r)
|
|
||||||
if burst < bandwidthWriteChunk {
|
|
||||||
burst = bandwidthWriteChunk
|
|
||||||
}
|
|
||||||
// Cap burst to avoid huge memory spikes on huge uplinks.
|
|
||||||
if burst > 4*bandwidthWriteChunk {
|
|
||||||
burst = 4 * bandwidthWriteChunk
|
|
||||||
}
|
|
||||||
lim.SetLimit(rate.Limit(r))
|
|
||||||
lim.SetBurst(burst)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// limitedResponseWriter rate-limits response body Write calls. Headers/WriteHeader
|
|
||||||
// are unlimited. Implements http.ResponseWriter (+ optional Flusher/Hijacker passthrough
|
|
||||||
// is intentionally omitted — SteamCache body path only needs Write).
|
|
||||||
type limitedResponseWriter struct {
|
|
||||||
http.ResponseWriter
|
|
||||||
lim *rate.Limiter
|
|
||||||
ctx context.Context
|
|
||||||
}
|
|
||||||
|
|
||||||
func (w *limitedResponseWriter) Write(p []byte) (int, error) {
|
|
||||||
if w.lim == nil || len(p) == 0 {
|
|
||||||
return w.ResponseWriter.Write(p)
|
|
||||||
}
|
|
||||||
ctx := w.ctx
|
|
||||||
if ctx == nil {
|
|
||||||
ctx = context.Background()
|
|
||||||
}
|
|
||||||
total := 0
|
|
||||||
for total < len(p) {
|
|
||||||
chunk := p[total:]
|
|
||||||
if len(chunk) > bandwidthWriteChunk {
|
|
||||||
chunk = chunk[:bandwidthWriteChunk]
|
|
||||||
}
|
|
||||||
if err := w.lim.WaitN(ctx, len(chunk)); err != nil {
|
|
||||||
return total, err
|
|
||||||
}
|
|
||||||
n, err := w.ResponseWriter.Write(chunk)
|
|
||||||
total += n
|
|
||||||
if err != nil {
|
|
||||||
return total, err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return total, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Unwrap exposes the underlying ResponseWriter for http.ResponseController etc.
|
|
||||||
func (w *limitedResponseWriter) Unwrap() http.ResponseWriter {
|
|
||||||
return w.ResponseWriter
|
|
||||||
}
|
|
||||||
@@ -1,128 +0,0 @@
|
|||||||
package steamcache
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestBandwidthFairShareRates(t *testing.T) {
|
|
||||||
b := newClientBandwidthLimiter(1000, 0)
|
|
||||||
lim1, rel1 := b.acquire("1.1.1.1")
|
|
||||||
defer rel1()
|
|
||||||
lim2, rel2 := b.acquire("2.2.2.2")
|
|
||||||
defer rel2()
|
|
||||||
if lim1 == nil || lim2 == nil {
|
|
||||||
t.Fatal("expected limiters")
|
|
||||||
}
|
|
||||||
// With 2 active clients, each should get ~500 bytes/sec.
|
|
||||||
got1 := float64(lim1.Limit())
|
|
||||||
got2 := float64(lim2.Limit())
|
|
||||||
if got1 < 400 || got1 > 600 || got2 < 400 || got2 > 600 {
|
|
||||||
t.Fatalf("fair-share rates = %v,%v want ~500", got1, got2)
|
|
||||||
}
|
|
||||||
rel2()
|
|
||||||
// After release, sole client should get full uplink.
|
|
||||||
lim1b, rel1b := b.acquire("1.1.1.1")
|
|
||||||
defer rel1b()
|
|
||||||
if float64(lim1b.Limit()) < 900 {
|
|
||||||
t.Fatalf("after release limit=%v want ~1000", lim1b.Limit())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBandwidthAbsoluteCap(t *testing.T) {
|
|
||||||
b := newClientBandwidthLimiter(0, 250)
|
|
||||||
lim, rel := b.acquire("9.9.9.9")
|
|
||||||
defer rel()
|
|
||||||
if lim == nil {
|
|
||||||
t.Fatal("expected limiter")
|
|
||||||
}
|
|
||||||
if float64(lim.Limit()) != 250 {
|
|
||||||
t.Fatalf("limit=%v want 250", lim.Limit())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBandwidthDisabled(t *testing.T) {
|
|
||||||
b := newClientBandwidthLimiter(0, 0)
|
|
||||||
lim, rel := b.acquire("9.9.9.9")
|
|
||||||
defer rel()
|
|
||||||
if lim != nil {
|
|
||||||
t.Fatal("expected nil limiter when disabled")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestLimitedResponseWriterShapes(t *testing.T) {
|
|
||||||
var buf bytes.Buffer
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
// Use a custom writer sink via ResponseRecorder is fine; WaitN will delay.
|
|
||||||
lim := newClientBandwidthLimiter(0, 2000) // 2KB/s
|
|
||||||
l, rel := lim.acquire("127.0.0.1")
|
|
||||||
defer rel()
|
|
||||||
w := &limitedResponseWriter{ResponseWriter: rec, lim: l, ctx: context.Background()}
|
|
||||||
payload := bytes.Repeat([]byte("x"), 4000)
|
|
||||||
start := time.Now()
|
|
||||||
n, err := w.Write(payload)
|
|
||||||
elapsed := time.Since(start)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if n != len(payload) {
|
|
||||||
t.Fatalf("wrote %d want %d", n, len(payload))
|
|
||||||
}
|
|
||||||
_ = buf
|
|
||||||
// 4000 bytes at 2000 B/s should take ~2s (allow slack for CI).
|
|
||||||
if elapsed < 1500*time.Millisecond {
|
|
||||||
t.Fatalf("elapsed %v too fast for 2KB/s shaping of 4KB", elapsed)
|
|
||||||
}
|
|
||||||
if elapsed > 8*time.Second {
|
|
||||||
t.Fatalf("elapsed %v unexpectedly slow", elapsed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestServeHTTPBandwidthCap(t *testing.T) {
|
|
||||||
body := bytes.Repeat([]byte("a"), 3000)
|
|
||||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.Header().Set("Content-Type", "application/octet-stream")
|
|
||||||
w.Header().Set("Content-Length", "3000")
|
|
||||||
w.WriteHeader(200)
|
|
||||||
_, _ = w.Write(body)
|
|
||||||
}))
|
|
||||||
t.Cleanup(upstream.Close)
|
|
||||||
|
|
||||||
sc, err := NewWithOptions(Options{
|
|
||||||
Address: "127.0.0.1:0",
|
|
||||||
MemorySize: "1MB",
|
|
||||||
DiskSize: "0",
|
|
||||||
Upstream: upstream.URL,
|
|
||||||
MemoryGC: "lru",
|
|
||||||
DiskGC: "lru",
|
|
||||||
MaxConcurrentRequests: 20,
|
|
||||||
MaxRequestsPerClient: 10,
|
|
||||||
MaxObjectSize: "0",
|
|
||||||
MaxBytesPerClientPerSec: 1500, // 1.5KB/s
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("NewWithOptions: %v", err)
|
|
||||||
}
|
|
||||||
t.Cleanup(func() { sc.Shutdown() })
|
|
||||||
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/depot/bw/chunk", nil)
|
|
||||||
req.Header.Set("User-Agent", "Valve/Steam HTTP Client 1.0")
|
|
||||||
rr := httptest.NewRecorder()
|
|
||||||
start := time.Now()
|
|
||||||
sc.ServeHTTP(rr, req)
|
|
||||||
elapsed := time.Since(start)
|
|
||||||
if rr.Code != 200 {
|
|
||||||
t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String())
|
|
||||||
}
|
|
||||||
got := rr.Body.Bytes()
|
|
||||||
if len(got) != len(body) {
|
|
||||||
t.Fatalf("body len=%d want %d", len(got), len(body))
|
|
||||||
}
|
|
||||||
if elapsed < time.Second {
|
|
||||||
t.Fatalf("elapsed %v too fast for shaping", elapsed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,199 +0,0 @@
|
|||||||
package steamcache
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"sync"
|
|
||||||
"sync/atomic"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
func coalescerWaiterCount(sc *SteamCache, cacheKey string) int32 {
|
|
||||||
sc.coalescer.mu.Lock()
|
|
||||||
defer sc.coalescer.mu.Unlock()
|
|
||||||
cr := sc.coalescer.requests[cacheKey]
|
|
||||||
if cr == nil {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
return cr.waitingCount.Load()
|
|
||||||
}
|
|
||||||
|
|
||||||
func steamCoalesceRequest(path string) *http.Request {
|
|
||||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
|
||||||
req.Header.Set("User-Agent", "Valve/Steam HTTP Client 1.0")
|
|
||||||
return req
|
|
||||||
}
|
|
||||||
|
|
||||||
func waitForCoalescerJoin(t *testing.T, sc *SteamCache, cacheKey string, n int, release func(), wg *sync.WaitGroup, upstreamCalls *atomic.Int64) {
|
|
||||||
t.Helper()
|
|
||||||
deadline := time.Now().Add(2 * time.Second)
|
|
||||||
var waiters int32
|
|
||||||
for {
|
|
||||||
waiters = coalescerWaiterCount(sc, cacheKey)
|
|
||||||
if waiters >= int32(n) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if time.Now().After(deadline) {
|
|
||||||
release()
|
|
||||||
wg.Wait()
|
|
||||||
t.Fatalf("coalescer waiters=%d want %d (upstreamCalls=%d)", waiters, n, upstreamCalls.Load())
|
|
||||||
}
|
|
||||||
time.Sleep(1 * time.Millisecond)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestCoalesceIdenticalMissesOneUpstreamGET holds the leader's upstream GET
|
|
||||||
// open until every concurrent client has joined the in-flight coalescer.
|
|
||||||
// Without that hold, later requests can become sequential HITs after the first
|
|
||||||
// miss fills, which would not prove coalescing.
|
|
||||||
func TestCoalesceIdenticalMissesOneUpstreamGET(t *testing.T) {
|
|
||||||
const nClients = 8
|
|
||||||
body := []byte("coalesced depot chunk body")
|
|
||||||
var upstreamCalls atomic.Int64
|
|
||||||
release := make(chan struct{})
|
|
||||||
var releaseOnce sync.Once
|
|
||||||
releaseUpstream := func() { releaseOnce.Do(func() { close(release) }) }
|
|
||||||
t.Cleanup(releaseUpstream)
|
|
||||||
|
|
||||||
f := func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
upstreamCalls.Add(1)
|
|
||||||
select {
|
|
||||||
case <-release:
|
|
||||||
case <-r.Context().Done():
|
|
||||||
return
|
|
||||||
}
|
|
||||||
w.Header().Set("Content-Type", "application/octet-stream")
|
|
||||||
_, _ = w.Write(body)
|
|
||||||
}
|
|
||||||
sc, _ := newTestCacheWithFakeUpstream(t, f, "1MB", "0")
|
|
||||||
sc.ResetMetrics()
|
|
||||||
|
|
||||||
const depotPath = "/depot/1684171/chunk/coalesce-inflight"
|
|
||||||
cacheKey, err := generateServiceCacheKey(depotPath, "steam")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
type clientResult struct {
|
|
||||||
status int
|
|
||||||
hdr string
|
|
||||||
body []byte
|
|
||||||
}
|
|
||||||
results := make([]clientResult, nClients)
|
|
||||||
|
|
||||||
var wg sync.WaitGroup
|
|
||||||
start := make(chan struct{})
|
|
||||||
wg.Add(nClients)
|
|
||||||
for i := 0; i < nClients; i++ {
|
|
||||||
go func(i int) {
|
|
||||||
defer wg.Done()
|
|
||||||
<-start
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
sc.ServeHTTP(rec, steamCoalesceRequest(depotPath))
|
|
||||||
results[i] = clientResult{
|
|
||||||
status: rec.Code,
|
|
||||||
hdr: rec.Header().Get("X-LanCache-Status"),
|
|
||||||
body: rec.Body.Bytes(),
|
|
||||||
}
|
|
||||||
}(i)
|
|
||||||
}
|
|
||||||
close(start)
|
|
||||||
|
|
||||||
waitForCoalescerJoin(t, sc, cacheKey, nClients, releaseUpstream, &wg, &upstreamCalls)
|
|
||||||
releaseUpstream()
|
|
||||||
wg.Wait()
|
|
||||||
|
|
||||||
if got := upstreamCalls.Load(); got != 1 {
|
|
||||||
t.Fatalf("expected exactly 1 upstream GET, got %d", got)
|
|
||||||
}
|
|
||||||
|
|
||||||
var miss, coalesced int
|
|
||||||
for i, r := range results {
|
|
||||||
if r.status != http.StatusOK {
|
|
||||||
t.Errorf("client %d: expected 200, got %d", i, r.status)
|
|
||||||
}
|
|
||||||
if !bytes.Equal(r.body, body) {
|
|
||||||
t.Errorf("client %d: body mismatch: got %q", i, r.body)
|
|
||||||
}
|
|
||||||
switch r.hdr {
|
|
||||||
case "MISS":
|
|
||||||
miss++
|
|
||||||
case "HIT-COALESCED":
|
|
||||||
coalesced++
|
|
||||||
default:
|
|
||||||
t.Errorf("client %d: unexpected X-LanCache-Status %q", i, r.hdr)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if miss != 1 {
|
|
||||||
t.Errorf("expected 1 MISS leader, got %d", miss)
|
|
||||||
}
|
|
||||||
if coalesced != nClients-1 {
|
|
||||||
t.Errorf("expected %d HIT-COALESCED waiters, got %d", nClients-1, coalesced)
|
|
||||||
}
|
|
||||||
if got := sc.GetMetrics().CacheCoalesced; got < int64(nClients-1) {
|
|
||||||
t.Errorf("CacheCoalesced=%d, want >= %d", got, nClients-1)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestCoalesceIdenticalMissesSharedUpstreamError is the 5xx sibling: waiters
|
|
||||||
// share the leader's failure instead of each hitting origin. Upstream 500 is
|
|
||||||
// retried, so the call count is the leader's retry budget (not N).
|
|
||||||
func TestCoalesceIdenticalMissesSharedUpstreamError(t *testing.T) {
|
|
||||||
const nClients = 8
|
|
||||||
var upstreamCalls atomic.Int64
|
|
||||||
release := make(chan struct{})
|
|
||||||
var releaseOnce sync.Once
|
|
||||||
releaseUpstream := func() { releaseOnce.Do(func() { close(release) }) }
|
|
||||||
t.Cleanup(releaseUpstream)
|
|
||||||
|
|
||||||
f := func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
upstreamCalls.Add(1)
|
|
||||||
select {
|
|
||||||
case <-release:
|
|
||||||
case <-r.Context().Done():
|
|
||||||
return
|
|
||||||
}
|
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
|
||||||
}
|
|
||||||
sc, _ := newTestCacheWithFakeUpstream(t, f, "1MB", "0")
|
|
||||||
sc.ResetMetrics()
|
|
||||||
|
|
||||||
const depotPath = "/depot/1684171/chunk/coalesce-inflight-err"
|
|
||||||
cacheKey, err := generateServiceCacheKey(depotPath, "steam")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
codes := make([]int, nClients)
|
|
||||||
var wg sync.WaitGroup
|
|
||||||
start := make(chan struct{})
|
|
||||||
wg.Add(nClients)
|
|
||||||
for i := 0; i < nClients; i++ {
|
|
||||||
go func(i int) {
|
|
||||||
defer wg.Done()
|
|
||||||
<-start
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
sc.ServeHTTP(rec, steamCoalesceRequest(depotPath))
|
|
||||||
codes[i] = rec.Code
|
|
||||||
}(i)
|
|
||||||
}
|
|
||||||
close(start)
|
|
||||||
|
|
||||||
waitForCoalescerJoin(t, sc, cacheKey, nClients, releaseUpstream, &wg, &upstreamCalls)
|
|
||||||
releaseUpstream()
|
|
||||||
wg.Wait()
|
|
||||||
|
|
||||||
if got := upstreamCalls.Load(); got < 1 || got >= int64(nClients) {
|
|
||||||
t.Fatalf("expected coalesced origin GETs (leader + retries, < %d waiters), got %d", nClients, got)
|
|
||||||
}
|
|
||||||
for i, code := range codes {
|
|
||||||
if code != http.StatusInternalServerError {
|
|
||||||
t.Errorf("client %d: expected 500, got %d", i, code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if got := sc.GetMetrics().Errors; got < int64(nClients) {
|
|
||||||
t.Errorf("Errors=%d, want >= %d (once per client)", got, nClients)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+12
-38
@@ -20,12 +20,10 @@ import (
|
|||||||
//
|
//
|
||||||
// On-disk format (documented here at top of format.go per Phase 2 plan; stable v1):
|
// On-disk format (documented here at top of format.go per Phase 2 plan; stable v1):
|
||||||
// File = header-line + raw-response-bytes
|
// File = header-line + raw-response-bytes
|
||||||
// header-line = "SC2C " + 64hex(bodySHA256) + " " + strconv(len(rawResp)) [+ " " + expires-unix] + "\n"
|
// header-line = "SC2C " + 64hex(bodySHA256) + " " + strconv(len(rawResp)) + "\n"
|
||||||
// Positive objects keep 3 fields. Negative (404/410) entries add an optional 4th
|
// raw-response-bytes = the exact bytes from reconstructRawResponse (HTTP/1.1 status\r\n + headers\r\n\r\n + body)
|
||||||
// expires-unix field (seconds since epoch); deserialize treats 3-field files as
|
// deserializeCacheFile: parses header, verifies size+SHA, returns CacheFileFormat.
|
||||||
// non-expiring. raw-response-bytes = reconstructRawResponse (HTTP/1.1 status\r\n
|
// No compression or extra fields. filterHopByHopHeaders is the shared helper
|
||||||
// + headers\r\n\r\n + body). deserializeCacheFile: parses header, verifies size+SHA,
|
|
||||||
// returns CacheFileFormat. No compression. filterHopByHopHeaders is the shared helper
|
|
||||||
// (used in streamCachedResponse, handler MISS, coalescing.complete).
|
// (used in streamCachedResponse, handler MISS, coalescing.complete).
|
||||||
const (
|
const (
|
||||||
CacheFileMagic = "SC2C" // SteamCache2 Cache
|
CacheFileMagic = "SC2C" // SteamCache2 Cache
|
||||||
@@ -36,19 +34,11 @@ type CacheFileFormat struct {
|
|||||||
ContentHash string // SHA256 hash of the response body (internal)
|
ContentHash string // SHA256 hash of the response body (internal)
|
||||||
ResponseSize int64 // Size of the entire HTTP response
|
ResponseSize int64 // Size of the entire HTTP response
|
||||||
Response []byte // The entire HTTP response as raw bytes
|
Response []byte // The entire HTTP response as raw bytes
|
||||||
ExpiresUnix int64 // 0 = no expiry (positive object); >0 = negative-entry expiry (unix seconds)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// serializeRawResponse serializes a raw HTTP response into our text-based cache format
|
// serializeRawResponse serializes a raw HTTP response into our text-based cache format
|
||||||
// (positive object: 3-field SC2C header, no expiry).
|
// upstreamHash and upstreamAlgo are used for verification during download but not stored
|
||||||
func serializeRawResponse(rawResponse []byte) ([]byte, error) {
|
func serializeRawResponse(rawResponse []byte) ([]byte, error) {
|
||||||
return serializeCacheFile(rawResponse, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
// serializeCacheFile writes the SC2C header plus raw response. expiresUnix > 0
|
|
||||||
// adds a 4th header field used for 404/410 negative entries; 0 keeps the
|
|
||||||
// 3-field positive layout so existing cache files stay valid.
|
|
||||||
func serializeCacheFile(rawResponse []byte, expiresUnix int64) ([]byte, error) {
|
|
||||||
// Extract body from raw response for hash calculation
|
// Extract body from raw response for hash calculation
|
||||||
bodyStart := bytes.Index(rawResponse, []byte("\r\n\r\n"))
|
bodyStart := bytes.Index(rawResponse, []byte("\r\n\r\n"))
|
||||||
if bodyStart == -1 {
|
if bodyStart == -1 {
|
||||||
@@ -63,13 +53,8 @@ func serializeCacheFile(rawResponse []byte, expiresUnix int64) ([]byte, error) {
|
|||||||
// Create text-based cache file
|
// Create text-based cache file
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
|
|
||||||
// First line: magic number, content hash, response size [, expires-unix]
|
// First line: magic number, content hash, response size
|
||||||
var headerLine string
|
headerLine := fmt.Sprintf("%s %s %d\n", CacheFileMagic, contentHash, len(rawResponse))
|
||||||
if expiresUnix > 0 {
|
|
||||||
headerLine = fmt.Sprintf("%s %s %d %d\n", CacheFileMagic, contentHash, len(rawResponse), expiresUnix)
|
|
||||||
} else {
|
|
||||||
headerLine = fmt.Sprintf("%s %s %d\n", CacheFileMagic, contentHash, len(rawResponse))
|
|
||||||
}
|
|
||||||
buf.WriteString(headerLine)
|
buf.WriteString(headerLine)
|
||||||
|
|
||||||
// Rest of the file: raw HTTP response
|
// Rest of the file: raw HTTP response
|
||||||
@@ -90,11 +75,11 @@ func deserializeCacheFile(data []byte) (*CacheFileFormat, error) {
|
|||||||
return nil, fmt.Errorf("invalid cache file format: no header line found")
|
return nil, fmt.Errorf("invalid cache file format: no header line found")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse header line: "SC2C <hash> <size>" or "SC2C <hash> <size> <expires-unix>"
|
// Parse header line: "SC2C <hash> <size>"
|
||||||
headerLine := string(data[:newlineIndex])
|
headerLine := string(data[:newlineIndex])
|
||||||
parts := strings.Fields(headerLine)
|
parts := strings.Fields(headerLine)
|
||||||
if len(parts) != 3 && len(parts) != 4 {
|
if len(parts) != 3 {
|
||||||
return nil, fmt.Errorf("invalid header format: expected 3 or 4 fields, got %d", len(parts))
|
return nil, fmt.Errorf("invalid header format: expected 3 fields, got %d", len(parts))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check magic number
|
// Check magic number
|
||||||
@@ -114,14 +99,6 @@ func deserializeCacheFile(data []byte) (*CacheFileFormat, error) {
|
|||||||
return nil, fmt.Errorf("invalid response size: %w", err)
|
return nil, fmt.Errorf("invalid response size: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var expiresUnix int64
|
|
||||||
if len(parts) == 4 {
|
|
||||||
expiresUnix, err = strconv.ParseInt(parts[3], 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("invalid expires unix: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract raw response (everything after the header line)
|
// Extract raw response (everything after the header line)
|
||||||
rawResponse := data[newlineIndex+1:]
|
rawResponse := data[newlineIndex+1:]
|
||||||
|
|
||||||
@@ -151,7 +128,6 @@ func deserializeCacheFile(data []byte) (*CacheFileFormat, error) {
|
|||||||
ContentHash: contentHash,
|
ContentHash: contentHash,
|
||||||
ResponseSize: responseSize,
|
ResponseSize: responseSize,
|
||||||
Response: rawResponse,
|
Response: rawResponse,
|
||||||
ExpiresUnix: expiresUnix,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return cacheFile, nil
|
return cacheFile, nil
|
||||||
@@ -246,11 +222,9 @@ func (sc *SteamCache) streamCachedResponse(w http.ResponseWriter, r *http.Reques
|
|||||||
bodyStart := responseReader.Size() - int64(responseReader.Len())
|
bodyStart := responseReader.Size() - int64(responseReader.Len())
|
||||||
bodyData := cacheFile.Response[bodyStart:]
|
bodyData := cacheFile.Response[bodyStart:]
|
||||||
|
|
||||||
// Handle Range requests on cached 200 bodies only. Cached 404/410 (negative
|
// Handle Range requests
|
||||||
// entries) are served as the stored status; slicing an error body as 206
|
|
||||||
// would be wrong.
|
|
||||||
rangeHeader := r.Header.Get("Range")
|
rangeHeader := r.Header.Get("Range")
|
||||||
if rangeHeader != "" && statusCode == http.StatusOK {
|
if rangeHeader != "" {
|
||||||
// Parse the range request
|
// Parse the range request
|
||||||
start, end, totalSize, valid := parseRangeHeader(rangeHeader, int64(len(bodyData)))
|
start, end, totalSize, valid := parseRangeHeader(rangeHeader, int64(len(bodyData)))
|
||||||
if !valid {
|
if !valid {
|
||||||
|
|||||||
+41
-159
@@ -34,18 +34,10 @@ type Options struct {
|
|||||||
// New config fields for hardening (max object size + trusted proxies)
|
// New config fields for hardening (max object size + trusted proxies)
|
||||||
MaxObjectSize string
|
MaxObjectSize string
|
||||||
TrustedProxies []string
|
TrustedProxies []string
|
||||||
|
|
||||||
// NegativeTTL is a Go duration string for 404/410 negative cache entries.
|
|
||||||
// Empty defaults to 5m. "0" / "0s" disables storing negatives.
|
|
||||||
NegativeTTL string
|
|
||||||
|
|
||||||
// Table-tier uplink bandwidth shaping (bytes/sec). Empty/0 = disabled.
|
|
||||||
UplinkBandwidth string
|
|
||||||
MaxBytesPerClientPerSec int64
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewWithOptions(o Options) (*SteamCache, error) {
|
func NewWithOptions(o Options) (*SteamCache, error) {
|
||||||
return New(o.Address, o.MemorySize, o.DiskSize, o.DiskPath, o.Upstream, o.MemoryGC, o.DiskGC, o.MaxConcurrentRequests, o.MaxRequestsPerClient, o.MaxObjectSize, o.TrustedProxies, o.NegativeTTL, o.UplinkBandwidth, o.MaxBytesPerClientPerSec)
|
return New(o.Address, o.MemorySize, o.DiskSize, o.DiskPath, o.Upstream, o.MemoryGC, o.DiskGC, o.MaxConcurrentRequests, o.MaxRequestsPerClient, o.MaxObjectSize, o.TrustedProxies)
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleSpecialEndpoints handles non-content paths (health, heartbeat, metrics) and
|
// handleSpecialEndpoints handles non-content paths (health, heartbeat, metrics) and
|
||||||
@@ -80,9 +72,9 @@ func (sc *SteamCache) handleSpecialEndpoints(w http.ResponseWriter, r *http.Requ
|
|||||||
}
|
}
|
||||||
|
|
||||||
if r.URL.String() == "/metrics" {
|
if r.URL.String() == "/metrics" {
|
||||||
// Prometheus text exposition format 0.0.4
|
// Return metrics in a simple text format
|
||||||
stats := sc.GetMetrics()
|
stats := sc.GetMetrics()
|
||||||
w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
|
w.Header().Set("Content-Type", "text/plain")
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
metrics.WriteText(w, stats)
|
metrics.WriteText(w, stats)
|
||||||
return true
|
return true
|
||||||
@@ -123,18 +115,6 @@ func (sc *SteamCache) handleCacheHit(w http.ResponseWriter, r *http.Request, cac
|
|||||||
Msg("Failed to deserialize cache file - removing corrupted entry")
|
Msg("Failed to deserialize cache file - removing corrupted entry")
|
||||||
_ = sc.vfs.Delete(cachePath) // best-effort cleanup of corrupt entry; failure non-fatal (logged)
|
_ = sc.vfs.Delete(cachePath) // best-effort cleanup of corrupt entry; failure non-fatal (logged)
|
||||||
} else {
|
} else {
|
||||||
if cacheFile.ExpiresUnix > 0 {
|
|
||||||
if time.Now().Unix() >= cacheFile.ExpiresUnix {
|
|
||||||
logger.Logger.Debug().
|
|
||||||
Str("key", cacheKey).
|
|
||||||
Str("url", urlPath).
|
|
||||||
Int64("expires_unix", cacheFile.ExpiresUnix).
|
|
||||||
Msg("Negative cache entry expired - treating as miss")
|
|
||||||
_ = sc.vfs.Delete(cachePath) // best-effort; miss path re-fetches
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
sc.metrics.IncrementNegativeCacheHits()
|
|
||||||
}
|
|
||||||
// Track cache hit metrics
|
// Track cache hit metrics
|
||||||
sc.metrics.IncrementCacheHits()
|
sc.metrics.IncrementCacheHits()
|
||||||
sc.metrics.AddResponseTime(time.Since(tstart))
|
sc.metrics.AddResponseTime(time.Since(tstart))
|
||||||
@@ -164,126 +144,6 @@ func (sc *SteamCache) handleCacheHit(w http.ResponseWriter, r *http.Request, cac
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
const maxNegativeBody = 64 * 1024
|
|
||||||
|
|
||||||
// handleNegativeUpstream serves a definitive upstream 404/410 to the client,
|
|
||||||
// stores a short-TTL negative marker in the same VFS cache (same key as a
|
|
||||||
// positive object), and completes coalesced waiters with that status.
|
|
||||||
func (sc *SteamCache) handleNegativeUpstream(w http.ResponseWriter, r *http.Request, resp *http.Response, coalescedReq *coalescedRequest, isNew bool, cachePath, cacheKey, urlPath, clientIP string, service *ServiceConfig, tstart time.Time) {
|
|
||||||
defer func() { _ = resp.Body.Close() }() // best-effort close of gone-status body
|
|
||||||
|
|
||||||
bodyData, err := io.ReadAll(io.LimitReader(resp.Body, maxNegativeBody))
|
|
||||||
if err != nil {
|
|
||||||
logger.Logger.Warn().
|
|
||||||
Err(err).
|
|
||||||
Str("key", cacheKey).
|
|
||||||
Str("url", urlPath).
|
|
||||||
Int("status_code", resp.StatusCode).
|
|
||||||
Msg("Failed to read upstream 404/410 body")
|
|
||||||
bodyData = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
rawResponse := sc.reconstructRawResponse(resp, bodyData)
|
|
||||||
|
|
||||||
for k, vv := range filterHopByHopHeaders(resp.Header) {
|
|
||||||
for _, v := range vv {
|
|
||||||
w.Header().Add(k, v)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
w.Header().Set("X-LanCache-Status", "MISS")
|
|
||||||
w.Header().Set("X-LanCache-Processed-By", "SteamCache2")
|
|
||||||
w.WriteHeader(resp.StatusCode)
|
|
||||||
if len(bodyData) > 0 {
|
|
||||||
_, _ = w.Write(bodyData) // client write error ignored (disconnect during 404/410 body send is not actionable)
|
|
||||||
}
|
|
||||||
|
|
||||||
sc.metrics.IncrementCacheMisses()
|
|
||||||
sc.metrics.IncrementUpstreamErrors()
|
|
||||||
sc.metrics.AddResponseTime(time.Since(tstart))
|
|
||||||
sc.metrics.AddBytesServed(int64(len(bodyData)))
|
|
||||||
sc.metrics.IncrementServiceRequests(service.Name)
|
|
||||||
|
|
||||||
if sc.negativeTTL > 0 {
|
|
||||||
expiresUnix := time.Now().Add(sc.negativeTTL).Unix()
|
|
||||||
cacheData, serErr := serializeCacheFile(rawResponse, expiresUnix)
|
|
||||||
if serErr != nil {
|
|
||||||
logger.Logger.Warn().
|
|
||||||
Str("key", cacheKey).
|
|
||||||
Str("url", urlPath).
|
|
||||||
Err(serErr).
|
|
||||||
Msg("Failed to serialize negative cache file")
|
|
||||||
sc.metrics.IncrementCacheWriteFailures()
|
|
||||||
sc.metrics.IncrementServiceError("serialize")
|
|
||||||
} else {
|
|
||||||
sc.writeCacheEntry(cachePath, cacheKey, urlPath, service.Name, cacheData)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if isNew {
|
|
||||||
coalescedResp := &http.Response{
|
|
||||||
StatusCode: resp.StatusCode,
|
|
||||||
Status: resp.Status,
|
|
||||||
Header: make(http.Header),
|
|
||||||
Body: io.NopCloser(bytes.NewReader(bodyData)),
|
|
||||||
}
|
|
||||||
for k, vv := range resp.Header {
|
|
||||||
coalescedResp.Header[k] = vv
|
|
||||||
}
|
|
||||||
coalescedReq.setResponseData(bodyData)
|
|
||||||
coalescedReq.complete(coalescedResp, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.Logger.Info().
|
|
||||||
Str("cache_key", cacheKey).
|
|
||||||
Str("url", urlPath).
|
|
||||||
Str("host", r.Host).
|
|
||||||
Str("client_ip", clientIP).
|
|
||||||
Str("service", service.Name).
|
|
||||||
Str("cache_status", "MISS").
|
|
||||||
Int("status_code", resp.StatusCode).
|
|
||||||
Int64("file_size", int64(len(bodyData))).
|
|
||||||
Dur("response_time", time.Since(tstart)).
|
|
||||||
Msg("cache request")
|
|
||||||
}
|
|
||||||
|
|
||||||
// writeCacheEntry stores serialized SC2C bytes at cachePath. Failures increment
|
|
||||||
// cache_write_failures; partial writes are deleted.
|
|
||||||
func (sc *SteamCache) writeCacheEntry(cachePath, cacheKey, urlPath, serviceName string, cacheData []byte) {
|
|
||||||
cacheWriter, err := sc.vfs.Create(cachePath, int64(len(cacheData)))
|
|
||||||
if err != nil {
|
|
||||||
logger.Logger.Warn().
|
|
||||||
Str("key", cacheKey).
|
|
||||||
Str("url", urlPath).
|
|
||||||
Err(err).
|
|
||||||
Msg("Failed to create cache file")
|
|
||||||
sc.metrics.IncrementCacheWriteFailures()
|
|
||||||
sc.metrics.IncrementServiceError("cache_create")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer func() { _ = cacheWriter.Close() }() // best-effort close of cache writer; errors on close (e.g. final sync) logged via prior write checks or non-fatal
|
|
||||||
|
|
||||||
bytesWritten, cacheErr := cacheWriter.Write(cacheData)
|
|
||||||
if cacheErr != nil || bytesWritten != len(cacheData) {
|
|
||||||
logger.Logger.Warn().
|
|
||||||
Str("key", cacheKey).
|
|
||||||
Str("url", urlPath).
|
|
||||||
Int("expected", len(cacheData)).
|
|
||||||
Int("written", bytesWritten).
|
|
||||||
Err(cacheErr).
|
|
||||||
Msg("Cache write failed or incomplete - removing corrupted entry")
|
|
||||||
sc.metrics.IncrementCacheWriteFailures()
|
|
||||||
sc.metrics.IncrementServiceError("cache_write")
|
|
||||||
_ = sc.vfs.Delete(cachePath) // best-effort removal of partial corrupt cache entry on write failure; non-fatal
|
|
||||||
return
|
|
||||||
}
|
|
||||||
logger.Logger.Debug().
|
|
||||||
Str("key", cacheKey).
|
|
||||||
Str("url", urlPath).
|
|
||||||
Str("service", serviceName).
|
|
||||||
Int("size", bytesWritten).
|
|
||||||
Msg("Successfully cached response")
|
|
||||||
}
|
|
||||||
|
|
||||||
// waitForCoalesced handles the follower path for a coalesced in-flight request.
|
// waitForCoalesced handles the follower path for a coalesced in-flight request.
|
||||||
// It waits on the broadcast doneCh, serves the buffered response (or error), updates
|
// It waits on the broadcast doneCh, serves the buffered response (or error), updates
|
||||||
// coalesced metrics, and returns (the caller in ServeHTTP does the outer return).
|
// coalesced metrics, and returns (the caller in ServeHTTP does the outer return).
|
||||||
@@ -417,15 +277,6 @@ func (sc *SteamCache) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Per-client uplink bandwidth shaping (table-tier). Distinct from concurrency limits above.
|
|
||||||
if sc.bandwidth != nil && sc.bandwidth.enabled() {
|
|
||||||
lim, release := sc.bandwidth.acquire(clientIP)
|
|
||||||
defer release()
|
|
||||||
if lim != nil {
|
|
||||||
w = &limitedResponseWriter{ResponseWriter: w, lim: lim, ctx: r.Context()}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if this is a request from a supported service
|
// Check if this is a request from a supported service
|
||||||
if service, isSupported := sc.detectService(r); isSupported {
|
if service, isSupported := sc.detectService(r); isSupported {
|
||||||
// Cache key is the path only, never the Host: Steam rotates CDN hostnames
|
// Cache key is the path only, never the Host: Steam rotates CDN hostnames
|
||||||
@@ -566,12 +417,12 @@ func (sc *SteamCache) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Retry logic. 404/410 are definitive gone: do not retry with backoff.
|
// Retry logic
|
||||||
backoffSchedule := []time.Duration{1 * time.Second, 3 * time.Second, 10 * time.Second}
|
backoffSchedule := []time.Duration{1 * time.Second, 3 * time.Second, 10 * time.Second}
|
||||||
var resp *http.Response
|
var resp *http.Response
|
||||||
for i, backoff := range backoffSchedule {
|
for i, backoff := range backoffSchedule {
|
||||||
resp, err = sc.client.Do(req)
|
resp, err = sc.client.Do(req)
|
||||||
if err == nil && (resp.StatusCode == http.StatusOK || isDefinitiveGone(resp.StatusCode)) {
|
if err == nil && resp.StatusCode == http.StatusOK {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
if i < len(backoffSchedule)-1 {
|
if i < len(backoffSchedule)-1 {
|
||||||
@@ -596,10 +447,6 @@ func (sc *SteamCache) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
if isDefinitiveGone(resp.StatusCode) {
|
|
||||||
sc.handleNegativeUpstream(w, r, resp, coalescedReq, isNew, cachePath, cacheKey, urlPath, clientIP, service, tstart)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
logger.Logger.Error().Int("status_code", resp.StatusCode).Str("url", req.URL.String()).Msg("Failed to fetch the requested URL (non-OK status after retries)")
|
logger.Logger.Error().Int("status_code", resp.StatusCode).Str("url", req.URL.String()).Msg("Failed to fetch the requested URL (non-OK status after retries)")
|
||||||
|
|
||||||
_ = resp.Body.Close() // best-effort close on non-OK upstream; primary error path
|
_ = resp.Body.Close() // best-effort close on non-OK upstream; primary error path
|
||||||
@@ -781,7 +628,42 @@ func (sc *SteamCache) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
sc.metrics.IncrementCacheWriteFailures()
|
sc.metrics.IncrementCacheWriteFailures()
|
||||||
sc.metrics.IncrementServiceError("serialize")
|
sc.metrics.IncrementServiceError("serialize")
|
||||||
} else {
|
} else {
|
||||||
sc.writeCacheEntry(cachePath, cacheKey, urlPath, service.Name, cacheData)
|
// Store the serialized cache data
|
||||||
|
cacheWriter, err := sc.vfs.Create(cachePath, int64(len(cacheData)))
|
||||||
|
if err == nil {
|
||||||
|
defer func() { _ = cacheWriter.Close() }() // best-effort close of cache writer; errors on close (e.g. final sync) logged via prior write checks or non-fatal
|
||||||
|
|
||||||
|
// Write the serialized cache data
|
||||||
|
bytesWritten, cacheErr := cacheWriter.Write(cacheData)
|
||||||
|
|
||||||
|
if cacheErr != nil || bytesWritten != len(cacheData) {
|
||||||
|
logger.Logger.Warn().
|
||||||
|
Str("key", cacheKey).
|
||||||
|
Str("url", urlPath).
|
||||||
|
Int("expected", len(cacheData)).
|
||||||
|
Int("written", bytesWritten).
|
||||||
|
Err(cacheErr).
|
||||||
|
Msg("Cache write failed or incomplete - removing corrupted entry")
|
||||||
|
sc.metrics.IncrementCacheWriteFailures()
|
||||||
|
sc.metrics.IncrementServiceError("cache_write")
|
||||||
|
_ = sc.vfs.Delete(cachePath) // best-effort removal of partial corrupt cache entry on write failure; non-fatal. Deferred cacheWriter.Close() (from earlier in block) runs after this on error unwind path (harmless per DiskFS design)
|
||||||
|
} else {
|
||||||
|
logger.Logger.Debug().
|
||||||
|
Str("key", cacheKey).
|
||||||
|
Str("url", urlPath).
|
||||||
|
Str("service", service.Name).
|
||||||
|
Int("size", bytesWritten).
|
||||||
|
Msg("Successfully cached response")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.Logger.Warn().
|
||||||
|
Str("key", cacheKey).
|
||||||
|
Str("url", urlPath).
|
||||||
|
Err(err).
|
||||||
|
Msg("Failed to create cache file")
|
||||||
|
sc.metrics.IncrementCacheWriteFailures()
|
||||||
|
sc.metrics.IncrementServiceError("cache_create")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Complete coalesced request with the original response
|
// Complete coalesced request with the original response
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ type Metrics struct {
|
|||||||
CacheHits int64
|
CacheHits int64
|
||||||
CacheMisses int64
|
CacheMisses int64
|
||||||
CacheCoalesced int64
|
CacheCoalesced int64
|
||||||
NegativeCacheHits int64 // 404/410 served from a still-valid negative cache entry
|
|
||||||
RangeCache int64 // Range requests served as 206 from an already-cached object (HIT)
|
RangeCache int64 // Range requests served as 206 from an already-cached object (HIT)
|
||||||
RangeUpstream int64 // Range requests that required an upstream fetch (full object), served as 206
|
RangeUpstream int64 // Range requests that required an upstream fetch (full object), served as 206
|
||||||
Errors int64
|
Errors int64
|
||||||
@@ -32,8 +31,6 @@ type Metrics struct {
|
|||||||
// Cache metrics
|
// Cache metrics
|
||||||
MemoryCacheSize int64
|
MemoryCacheSize int64
|
||||||
DiskCacheSize int64
|
DiskCacheSize int64
|
||||||
MemoryCacheCapacity int64 // configured memory capacity (bytes)
|
|
||||||
DiskCacheCapacity int64 // configured disk capacity (bytes); 0 when no disk
|
|
||||||
MemoryCacheHits int64
|
MemoryCacheHits int64
|
||||||
DiskCacheHits int64
|
DiskCacheHits int64
|
||||||
Promotions int64
|
Promotions int64
|
||||||
@@ -82,11 +79,6 @@ func (m *Metrics) IncrementCacheMisses() {
|
|||||||
atomic.AddInt64(&m.CacheMisses, 1)
|
atomic.AddInt64(&m.CacheMisses, 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
// IncrementNegativeCacheHits increments hits served from a 404/410 negative entry.
|
|
||||||
func (m *Metrics) IncrementNegativeCacheHits() {
|
|
||||||
atomic.AddInt64(&m.NegativeCacheHits, 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// IncrementCacheCoalesced increments the coalesced request counter
|
// IncrementCacheCoalesced increments the coalesced request counter
|
||||||
func (m *Metrics) IncrementCacheCoalesced() {
|
func (m *Metrics) IncrementCacheCoalesced() {
|
||||||
atomic.AddInt64(&m.CacheCoalesced, 1)
|
atomic.AddInt64(&m.CacheCoalesced, 1)
|
||||||
@@ -139,17 +131,6 @@ func (m *Metrics) SetDiskCacheSize(size int64) {
|
|||||||
atomic.StoreInt64(&m.DiskCacheSize, size)
|
atomic.StoreInt64(&m.DiskCacheSize, size)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetMemoryCacheCapacity sets the configured memory cache capacity in bytes.
|
|
||||||
func (m *Metrics) SetMemoryCacheCapacity(capacity int64) {
|
|
||||||
atomic.StoreInt64(&m.MemoryCacheCapacity, capacity)
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetDiskCacheCapacity sets the configured disk cache capacity in bytes
|
|
||||||
// (0 when no disk is configured).
|
|
||||||
func (m *Metrics) SetDiskCacheCapacity(capacity int64) {
|
|
||||||
atomic.StoreInt64(&m.DiskCacheCapacity, capacity)
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetDiskTierReady sets whether the disk slow tier is attached (1) or still pending (0).
|
// SetDiskTierReady sets whether the disk slow tier is attached (1) or still pending (0).
|
||||||
// Memory-only (no disk) also uses 1 — meaning "not waiting on disk attach". Reset does not clear this.
|
// Memory-only (no disk) also uses 1 — meaning "not waiting on disk attach". Reset does not clear this.
|
||||||
func (m *Metrics) SetDiskTierReady(ready int64) {
|
func (m *Metrics) SetDiskTierReady(ready int64) {
|
||||||
@@ -261,17 +242,11 @@ func (m *Metrics) GetStats() *Stats {
|
|||||||
serviceErrors[k] = v
|
serviceErrors[k] = v
|
||||||
}
|
}
|
||||||
|
|
||||||
memoryCacheSize := atomic.LoadInt64(&m.MemoryCacheSize)
|
|
||||||
diskCacheSize := atomic.LoadInt64(&m.DiskCacheSize)
|
|
||||||
memoryCacheCapacity := atomic.LoadInt64(&m.MemoryCacheCapacity)
|
|
||||||
diskCacheCapacity := atomic.LoadInt64(&m.DiskCacheCapacity)
|
|
||||||
|
|
||||||
return &Stats{
|
return &Stats{
|
||||||
TotalRequests: totalRequests,
|
TotalRequests: totalRequests,
|
||||||
CacheHits: cacheHits,
|
CacheHits: cacheHits,
|
||||||
CacheMisses: cacheMisses,
|
CacheMisses: cacheMisses,
|
||||||
CacheCoalesced: atomic.LoadInt64(&m.CacheCoalesced),
|
CacheCoalesced: atomic.LoadInt64(&m.CacheCoalesced),
|
||||||
NegativeCacheHits: atomic.LoadInt64(&m.NegativeCacheHits),
|
|
||||||
RangeCache: atomic.LoadInt64(&m.RangeCache),
|
RangeCache: atomic.LoadInt64(&m.RangeCache),
|
||||||
RangeUpstream: atomic.LoadInt64(&m.RangeUpstream),
|
RangeUpstream: atomic.LoadInt64(&m.RangeUpstream),
|
||||||
Errors: atomic.LoadInt64(&m.Errors),
|
Errors: atomic.LoadInt64(&m.Errors),
|
||||||
@@ -280,11 +255,8 @@ func (m *Metrics) GetStats() *Stats {
|
|||||||
AvgResponseTime: avgResponseTime,
|
AvgResponseTime: avgResponseTime,
|
||||||
TotalBytesServed: atomic.LoadInt64(&m.TotalBytesServed),
|
TotalBytesServed: atomic.LoadInt64(&m.TotalBytesServed),
|
||||||
TotalBytesSaved: atomic.LoadInt64(&m.TotalBytesSaved),
|
TotalBytesSaved: atomic.LoadInt64(&m.TotalBytesSaved),
|
||||||
MemoryCacheSize: memoryCacheSize,
|
MemoryCacheSize: atomic.LoadInt64(&m.MemoryCacheSize),
|
||||||
DiskCacheSize: diskCacheSize,
|
DiskCacheSize: atomic.LoadInt64(&m.DiskCacheSize),
|
||||||
MemoryCacheCapacity: memoryCacheCapacity,
|
|
||||||
DiskCacheCapacity: diskCacheCapacity,
|
|
||||||
DiskCacheFullRatio: diskFullRatio(diskCacheSize, diskCacheCapacity),
|
|
||||||
DiskTierReady: atomic.LoadInt64(&m.DiskTierReady),
|
DiskTierReady: atomic.LoadInt64(&m.DiskTierReady),
|
||||||
MemoryCacheHits: atomic.LoadInt64(&m.MemoryCacheHits),
|
MemoryCacheHits: atomic.LoadInt64(&m.MemoryCacheHits),
|
||||||
DiskCacheHits: atomic.LoadInt64(&m.DiskCacheHits),
|
DiskCacheHits: atomic.LoadInt64(&m.DiskCacheHits),
|
||||||
@@ -300,26 +272,12 @@ func (m *Metrics) GetStats() *Stats {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// diskFullRatio is size / capacity clamped to [0,1].
|
|
||||||
// It is 0 when no disk is configured or the capacity is 0.
|
|
||||||
func diskFullRatio(size, capacity int64) float64 {
|
|
||||||
if size <= 0 || capacity <= 0 {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
ratio := float64(size) / float64(capacity)
|
|
||||||
if ratio > 1 {
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
return ratio
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reset resets all metrics to zero
|
// Reset resets all metrics to zero
|
||||||
func (m *Metrics) Reset() {
|
func (m *Metrics) Reset() {
|
||||||
atomic.StoreInt64(&m.TotalRequests, 0)
|
atomic.StoreInt64(&m.TotalRequests, 0)
|
||||||
atomic.StoreInt64(&m.CacheHits, 0)
|
atomic.StoreInt64(&m.CacheHits, 0)
|
||||||
atomic.StoreInt64(&m.CacheMisses, 0)
|
atomic.StoreInt64(&m.CacheMisses, 0)
|
||||||
atomic.StoreInt64(&m.CacheCoalesced, 0)
|
atomic.StoreInt64(&m.CacheCoalesced, 0)
|
||||||
atomic.StoreInt64(&m.NegativeCacheHits, 0)
|
|
||||||
atomic.StoreInt64(&m.RangeCache, 0)
|
atomic.StoreInt64(&m.RangeCache, 0)
|
||||||
atomic.StoreInt64(&m.RangeUpstream, 0)
|
atomic.StoreInt64(&m.RangeUpstream, 0)
|
||||||
atomic.StoreInt64(&m.Errors, 0)
|
atomic.StoreInt64(&m.Errors, 0)
|
||||||
@@ -352,7 +310,6 @@ type Stats struct {
|
|||||||
CacheHits int64
|
CacheHits int64
|
||||||
CacheMisses int64
|
CacheMisses int64
|
||||||
CacheCoalesced int64
|
CacheCoalesced int64
|
||||||
NegativeCacheHits int64
|
|
||||||
RangeCache int64
|
RangeCache int64
|
||||||
RangeUpstream int64
|
RangeUpstream int64
|
||||||
Errors int64
|
Errors int64
|
||||||
@@ -364,9 +321,6 @@ type Stats struct {
|
|||||||
MemoryCacheSize int64
|
MemoryCacheSize int64
|
||||||
|
|
||||||
DiskCacheSize int64
|
DiskCacheSize int64
|
||||||
MemoryCacheCapacity int64 // configured memory capacity (bytes)
|
|
||||||
DiskCacheCapacity int64 // configured disk capacity (bytes); 0 when no disk
|
|
||||||
DiskCacheFullRatio float64 // disk_cache_size / disk_cache_capacity, clamped to [0,1]; 0 when no disk or capacity is 0
|
|
||||||
DiskTierReady int64
|
DiskTierReady int64
|
||||||
MemoryCacheHits int64
|
MemoryCacheHits int64
|
||||||
DiskCacheHits int64
|
DiskCacheHits int64
|
||||||
@@ -381,62 +335,41 @@ type Stats struct {
|
|||||||
LastResetTime time.Time
|
LastResetTime time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// WriteText emits Prometheus text exposition format 0.0.4 to the ResponseWriter.
|
// WriteText emits the Prometheus-style text metrics to the ResponseWriter.
|
||||||
// Each metric family is # HELP, then # TYPE, then one or more sample lines.
|
// Promoted from internal handler per Phase 3 for better package ownership.
|
||||||
// Metric names are stable; labeled series keep service=%q (Prometheus-valid quotes).
|
|
||||||
// All fmt.Fprintf errors are intentionally discarded via _ = : this is a best-effort
|
// All fmt.Fprintf errors are intentionally discarded via _ = : this is a best-effort
|
||||||
// read-only debug endpoint; client disconnects or write errors during metrics dump
|
// read-only debug endpoint; client disconnects or write errors during metrics dump
|
||||||
// are not actionable (do not affect cache correctness or require retries).
|
// are not actionable (do not affect cache correctness or require retries).
|
||||||
func WriteText(w http.ResponseWriter, stats *Stats) {
|
func WriteText(w http.ResponseWriter, stats *Stats) {
|
||||||
writeInt(w, "total_requests", "Total HTTP requests handled.", "counter", stats.TotalRequests)
|
_, _ = fmt.Fprintf(w, "# SteamCache2 Metrics\n")
|
||||||
writeInt(w, "cache_hits", "Requests served from cache.", "counter", stats.CacheHits)
|
_, _ = fmt.Fprintf(w, "total_requests %d\n", stats.TotalRequests)
|
||||||
writeInt(w, "cache_misses", "Requests not found in cache.", "counter", stats.CacheMisses)
|
_, _ = fmt.Fprintf(w, "cache_hits %d\n", stats.CacheHits)
|
||||||
writeInt(w, "negative_cache_hits", "404/410 served from a still-valid negative cache entry.", "counter", stats.NegativeCacheHits)
|
_, _ = fmt.Fprintf(w, "cache_misses %d\n", stats.CacheMisses)
|
||||||
writeInt(w, "cache_coalesced", "Requests coalesced onto an in-flight upstream fetch.", "counter", stats.CacheCoalesced)
|
_, _ = fmt.Fprintf(w, "cache_coalesced %d\n", stats.CacheCoalesced)
|
||||||
writeInt(w, "range_cache", "Range requests served as 206 from an already-cached object.", "counter", stats.RangeCache)
|
_, _ = fmt.Fprintf(w, "range_cache %d\n", stats.RangeCache)
|
||||||
writeInt(w, "range_upstream", "Range requests that required an upstream fetch, served as 206.", "counter", stats.RangeUpstream)
|
_, _ = fmt.Fprintf(w, "range_upstream %d\n", stats.RangeUpstream)
|
||||||
writeInt(w, "errors", "Request errors.", "counter", stats.Errors)
|
_, _ = fmt.Fprintf(w, "errors %d\n", stats.Errors)
|
||||||
writeInt(w, "rate_limited", "Requests rejected by rate limiting.", "counter", stats.RateLimited)
|
_, _ = fmt.Fprintf(w, "rate_limited %d\n", stats.RateLimited)
|
||||||
writeInt(w, "upstream_errors", "Errors talking to upstream.", "counter", stats.UpstreamErrors)
|
_, _ = fmt.Fprintf(w, "upstream_errors %d\n", stats.UpstreamErrors)
|
||||||
writeInt(w, "cache_write_failures", "Failures writing objects into cache.", "counter", stats.CacheWriteFailures)
|
_, _ = fmt.Fprintf(w, "cache_write_failures %d\n", stats.CacheWriteFailures)
|
||||||
writeInt(w, "memory_cache_hits", "Hits served from the memory tier.", "counter", stats.MemoryCacheHits)
|
_, _ = fmt.Fprintf(w, "memory_cache_hits %d\n", stats.MemoryCacheHits)
|
||||||
writeInt(w, "disk_cache_hits", "Hits served from the disk tier.", "counter", stats.DiskCacheHits)
|
_, _ = fmt.Fprintf(w, "disk_cache_hits %d\n", stats.DiskCacheHits)
|
||||||
writeInt(w, "promotions", "Objects promoted from disk to memory.", "counter", stats.Promotions)
|
_, _ = fmt.Fprintf(w, "promotions %d\n", stats.Promotions)
|
||||||
writeInt(w, "evictions", "Objects evicted from cache.", "counter", stats.Evictions)
|
_, _ = fmt.Fprintf(w, "evictions %d\n", stats.Evictions)
|
||||||
writeInt(w, "capacity_pressure_events", "Soft eviction under the memory or disk cap, and/or disk ENOSPC.", "counter", stats.CapacityPressureEvents)
|
_, _ = fmt.Fprintf(w, "capacity_pressure_events %d\n", stats.CapacityPressureEvents)
|
||||||
|
|
||||||
writeHelpType(w, "service_errors", "Errors attributed to a named service.", "counter")
|
|
||||||
for svc, cnt := range stats.ServiceErrors {
|
for svc, cnt := range stats.ServiceErrors {
|
||||||
_, _ = fmt.Fprintf(w, "service_errors{service=%q} %d\n", svc, cnt)
|
_, _ = fmt.Fprintf(w, "service_errors{service=%q} %d\n", svc, cnt)
|
||||||
}
|
}
|
||||||
writeHelpType(w, "service_requests", "Requests attributed to a named service.", "counter")
|
|
||||||
for svc, cnt := range stats.ServiceRequests {
|
for svc, cnt := range stats.ServiceRequests {
|
||||||
_, _ = fmt.Fprintf(w, "service_requests{service=%q} %d\n", svc, cnt)
|
_, _ = fmt.Fprintf(w, "service_requests{service=%q} %d\n", svc, cnt)
|
||||||
}
|
}
|
||||||
|
_, _ = fmt.Fprintf(w, "hit_rate %.4f\n", stats.HitRate)
|
||||||
|
_, _ = fmt.Fprintf(w, "avg_response_time_ms %.2f\n", float64(stats.AvgResponseTime.Nanoseconds())/1e6)
|
||||||
|
_, _ = fmt.Fprintf(w, "total_bytes_served %d\n", stats.TotalBytesServed)
|
||||||
|
_, _ = fmt.Fprintf(w, "total_bytes_saved %d\n", stats.TotalBytesSaved)
|
||||||
|
|
||||||
writeFloat(w, "hit_rate", "Cache hits divided by total requests.", "gauge", "%.4f", stats.HitRate)
|
_, _ = fmt.Fprintf(w, "memory_cache_size %d\n", stats.MemoryCacheSize)
|
||||||
writeFloat(w, "avg_response_time_ms", "Average response time in milliseconds.", "gauge", "%.2f", float64(stats.AvgResponseTime.Nanoseconds())/1e6)
|
_, _ = fmt.Fprintf(w, "disk_cache_size %d\n", stats.DiskCacheSize)
|
||||||
writeInt(w, "total_bytes_served", "Total bytes sent to clients.", "counter", stats.TotalBytesServed)
|
_, _ = fmt.Fprintf(w, "disk_tier_ready %d\n", stats.DiskTierReady)
|
||||||
writeInt(w, "total_bytes_saved", "Bytes served from cache instead of being re-downloaded from upstream.", "counter", stats.TotalBytesSaved)
|
_, _ = fmt.Fprintf(w, "uptime_seconds %.2f\n", stats.Uptime.Seconds())
|
||||||
writeInt(w, "memory_cache_size", "Current memory cache size in bytes.", "gauge", stats.MemoryCacheSize)
|
|
||||||
writeInt(w, "memory_cache_capacity", "Configured memory cache capacity in bytes.", "gauge", stats.MemoryCacheCapacity)
|
|
||||||
writeInt(w, "disk_cache_size", "Current disk cache size in bytes.", "gauge", stats.DiskCacheSize)
|
|
||||||
writeInt(w, "disk_cache_capacity", "Configured disk cache capacity in bytes; 0 when no disk is configured.", "gauge", stats.DiskCacheCapacity)
|
|
||||||
writeFloat(w, "disk_cache_full_ratio", "disk_cache_size / disk_cache_capacity in [0,1]; 0 when no disk or capacity is 0.", "gauge", "%.4f", stats.DiskCacheFullRatio)
|
|
||||||
writeInt(w, "disk_tier_ready", "1 if the disk tier is attached or no disk is configured; 0 while attach is pending.", "gauge", stats.DiskTierReady)
|
|
||||||
writeFloat(w, "uptime_seconds", "Process uptime in seconds.", "gauge", "%.2f", stats.Uptime.Seconds())
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeHelpType(w http.ResponseWriter, name, help, typ string) {
|
|
||||||
_, _ = fmt.Fprintf(w, "# HELP %s %s\n# TYPE %s %s\n", name, help, name, typ)
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeInt(w http.ResponseWriter, name, help, typ string, v int64) {
|
|
||||||
writeHelpType(w, name, help, typ)
|
|
||||||
_, _ = fmt.Fprintf(w, "%s %d\n", name, v)
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeFloat(w http.ResponseWriter, name, help, typ, valFmt string, v float64) {
|
|
||||||
writeHelpType(w, name, help, typ)
|
|
||||||
_, _ = fmt.Fprintf(w, "%s "+valFmt+"\n", name, v)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,9 +4,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"errors"
|
"errors"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"strings"
|
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestCapacityPressureEventsWriteTextAndReset(t *testing.T) {
|
func TestCapacityPressureEventsWriteTextAndReset(t *testing.T) {
|
||||||
@@ -49,15 +47,6 @@ func TestCapacityPressureEventsWriteTextAndReset(t *testing.T) {
|
|||||||
if !bytes.Contains(body, []byte("evictions 2")) {
|
if !bytes.Contains(body, []byte("evictions 2")) {
|
||||||
t.Errorf("WriteText missing evictions 2: %q", rec.Body.String())
|
t.Errorf("WriteText missing evictions 2: %q", rec.Body.String())
|
||||||
}
|
}
|
||||||
if !bytes.Contains(body, []byte("# HELP capacity_pressure_events")) {
|
|
||||||
t.Errorf("WriteText missing # HELP capacity_pressure_events: %q", rec.Body.String())
|
|
||||||
}
|
|
||||||
if !bytes.Contains(body, []byte("# TYPE capacity_pressure_events counter")) {
|
|
||||||
t.Errorf("WriteText missing # TYPE capacity_pressure_events counter: %q", rec.Body.String())
|
|
||||||
}
|
|
||||||
if !bytes.Contains(body, []byte("# TYPE evictions counter")) {
|
|
||||||
t.Errorf("WriteText missing # TYPE evictions counter: %q", rec.Body.String())
|
|
||||||
}
|
|
||||||
|
|
||||||
m.Reset()
|
m.Reset()
|
||||||
st = m.GetStats()
|
st = m.GetStats()
|
||||||
@@ -72,169 +61,3 @@ func TestNoteSoftEvictionNilMetrics(t *testing.T) {
|
|||||||
NoteSoftEviction(nil, "memory", 10)
|
NoteSoftEviction(nil, "memory", 10)
|
||||||
NoteNoSpace(nil, errors.New("ENOSPC"))
|
NoteNoSpace(nil, errors.New("ENOSPC"))
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestWriteTextPrometheusExposition(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
st := &Stats{
|
|
||||||
TotalRequests: 10,
|
|
||||||
CacheHits: 4,
|
|
||||||
CacheMisses: 6,
|
|
||||||
NegativeCacheHits: 1,
|
|
||||||
CacheCoalesced: 2,
|
|
||||||
RangeCache: 3,
|
|
||||||
RangeUpstream: 5,
|
|
||||||
Errors: 1,
|
|
||||||
RateLimited: 1,
|
|
||||||
UpstreamErrors: 1,
|
|
||||||
CacheWriteFailures: 1,
|
|
||||||
MemoryCacheHits: 2,
|
|
||||||
DiskCacheHits: 2,
|
|
||||||
Promotions: 1,
|
|
||||||
Evictions: 1,
|
|
||||||
CapacityPressureEvents: 1,
|
|
||||||
ServiceErrors: map[string]int64{"steam": 2},
|
|
||||||
ServiceRequests: map[string]int64{"steam": 7},
|
|
||||||
HitRate: 0.4,
|
|
||||||
AvgResponseTime: 2 * time.Millisecond,
|
|
||||||
TotalBytesServed: 100,
|
|
||||||
TotalBytesSaved: 50,
|
|
||||||
MemoryCacheSize: 8,
|
|
||||||
DiskCacheSize: 16,
|
|
||||||
MemoryCacheCapacity: 8,
|
|
||||||
DiskCacheCapacity: 32,
|
|
||||||
DiskCacheFullRatio: 0.5,
|
|
||||||
DiskTierReady: 1,
|
|
||||||
Uptime: 3 * time.Second,
|
|
||||||
}
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
WriteText(rec, st)
|
|
||||||
body := rec.Body.String()
|
|
||||||
|
|
||||||
if strings.Contains(body, "# SteamCache2 Metrics") {
|
|
||||||
t.Error("non-standard # SteamCache2 Metrics banner must not be present")
|
|
||||||
}
|
|
||||||
|
|
||||||
counters := []string{
|
|
||||||
"total_requests", "cache_hits", "cache_misses", "negative_cache_hits",
|
|
||||||
"cache_coalesced", "range_cache", "range_upstream", "errors", "rate_limited",
|
|
||||||
"upstream_errors", "cache_write_failures", "memory_cache_hits", "disk_cache_hits",
|
|
||||||
"promotions", "evictions", "capacity_pressure_events", "service_errors",
|
|
||||||
"service_requests", "total_bytes_served", "total_bytes_saved",
|
|
||||||
}
|
|
||||||
gauges := []string{
|
|
||||||
"hit_rate", "avg_response_time_ms", "memory_cache_size", "disk_cache_size",
|
|
||||||
"memory_cache_capacity", "disk_cache_capacity", "disk_cache_full_ratio",
|
|
||||||
"disk_tier_ready", "uptime_seconds",
|
|
||||||
}
|
|
||||||
for _, name := range counters {
|
|
||||||
assertHelpType(t, body, name, "counter")
|
|
||||||
}
|
|
||||||
for _, name := range gauges {
|
|
||||||
assertHelpType(t, body, name, "gauge")
|
|
||||||
}
|
|
||||||
|
|
||||||
if !strings.Contains(body, `service_errors{service="steam"} 2`) {
|
|
||||||
t.Errorf("missing labeled service_errors sample: %q", body)
|
|
||||||
}
|
|
||||||
if !strings.Contains(body, `service_requests{service="steam"} 7`) {
|
|
||||||
t.Errorf("missing labeled service_requests sample: %q", body)
|
|
||||||
}
|
|
||||||
if !strings.Contains(body, "disk_tier_ready 1\n") {
|
|
||||||
t.Errorf("missing disk_tier_ready 1 sample: %q", body)
|
|
||||||
}
|
|
||||||
if !strings.Contains(body, "memory_cache_capacity 8\n") {
|
|
||||||
t.Errorf("missing memory_cache_capacity 8 sample: %q", body)
|
|
||||||
}
|
|
||||||
if !strings.Contains(body, "disk_cache_capacity 32\n") {
|
|
||||||
t.Errorf("missing disk_cache_capacity 32 sample: %q", body)
|
|
||||||
}
|
|
||||||
if !strings.Contains(body, "disk_cache_full_ratio 0.5000\n") {
|
|
||||||
t.Errorf("missing disk_cache_full_ratio 0.5000 sample: %q", body)
|
|
||||||
}
|
|
||||||
if !strings.Contains(body, "range_cache 3\n") {
|
|
||||||
t.Errorf("missing range_cache 3 sample: %q", body)
|
|
||||||
}
|
|
||||||
if !strings.Contains(body, "negative_cache_hits 1\n") {
|
|
||||||
t.Errorf("missing negative_cache_hits 1 sample: %q", body)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDiskCacheFullRatioInGetStats(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
cases := []struct {
|
|
||||||
name string
|
|
||||||
size int64
|
|
||||||
capacity int64
|
|
||||||
wantRatio float64
|
|
||||||
wantCapacity int64
|
|
||||||
}{
|
|
||||||
{"no disk (capacity 0)", 0, 0, 0, 0},
|
|
||||||
{"zero size with capacity", 0, 1024, 0, 1024},
|
|
||||||
{"half full", 512, 1024, 0.5, 1024},
|
|
||||||
{"exact full", 1024, 1024, 1, 1024},
|
|
||||||
{"size above capacity clamps to 1", 2048, 1024, 1, 1024},
|
|
||||||
}
|
|
||||||
for _, tc := range cases {
|
|
||||||
tc := tc
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
m := NewMetrics()
|
|
||||||
m.SetDiskCacheSize(tc.size)
|
|
||||||
m.SetDiskCacheCapacity(tc.capacity)
|
|
||||||
st := m.GetStats()
|
|
||||||
if st.DiskCacheCapacity != tc.wantCapacity {
|
|
||||||
t.Fatalf("DiskCacheCapacity=%d, want %d", st.DiskCacheCapacity, tc.wantCapacity)
|
|
||||||
}
|
|
||||||
if st.DiskCacheFullRatio != tc.wantRatio {
|
|
||||||
t.Fatalf("DiskCacheFullRatio=%v, want %v", st.DiskCacheFullRatio, tc.wantRatio)
|
|
||||||
}
|
|
||||||
if st.DiskCacheFullRatio < 0 || st.DiskCacheFullRatio > 1 {
|
|
||||||
t.Fatalf("DiskCacheFullRatio=%v outside [0,1]", st.DiskCacheFullRatio)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Memory capacity is a plain passthrough, and capacity survives Reset
|
|
||||||
// (re-derived by GetMetrics, like MemoryCacheSize/DiskTierReady).
|
|
||||||
m := NewMetrics()
|
|
||||||
m.SetMemoryCacheCapacity(4096)
|
|
||||||
if got := m.GetStats().MemoryCacheCapacity; got != 4096 {
|
|
||||||
t.Fatalf("MemoryCacheCapacity=%d, want 4096", got)
|
|
||||||
}
|
|
||||||
m.Reset()
|
|
||||||
if got := m.GetStats().MemoryCacheCapacity; got != 4096 {
|
|
||||||
t.Fatalf("MemoryCacheCapacity=%d after Reset, want 4096 (config snapshot, like size gauges)", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func assertHelpType(t *testing.T, body, name, typ string) {
|
|
||||||
t.Helper()
|
|
||||||
help := "# HELP " + name + " "
|
|
||||||
typeLine := "# TYPE " + name + " " + typ
|
|
||||||
iHelp := strings.Index(body, help)
|
|
||||||
if iHelp < 0 {
|
|
||||||
t.Errorf("missing %q", help)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
iType := strings.Index(body[iHelp:], typeLine)
|
|
||||||
if iType < 0 {
|
|
||||||
t.Errorf("missing %q after HELP for %s", typeLine, name)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
afterType := body[iHelp+iType+len(typeLine):]
|
|
||||||
if !strings.HasPrefix(afterType, "\n") {
|
|
||||||
t.Errorf("# TYPE %s not followed by newline", name)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
sample := afterType[1:]
|
|
||||||
if !strings.HasPrefix(sample, name+" ") && !strings.HasPrefix(sample, name+"{") {
|
|
||||||
t.Errorf("sample for %s does not follow TYPE; next line starts %q", name, firstLine(sample))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func firstLine(s string) string {
|
|
||||||
if i := strings.IndexByte(s, '\n'); i >= 0 {
|
|
||||||
return s[:i]
|
|
||||||
}
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,222 +0,0 @@
|
|||||||
package steamcache
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"strings"
|
|
||||||
"sync/atomic"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
func steamGet(t *testing.T, sc *SteamCache, path string) *httptest.ResponseRecorder {
|
|
||||||
t.Helper()
|
|
||||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
|
||||||
req.Header.Set("User-Agent", "Valve/Steam HTTP Client 1.0")
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
sc.ServeHTTP(rec, req)
|
|
||||||
return rec
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNegativeCache404(t *testing.T) {
|
|
||||||
var upstreamHits atomic.Int64
|
|
||||||
f := func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
upstreamHits.Add(1)
|
|
||||||
http.Error(w, "not found", http.StatusNotFound)
|
|
||||||
}
|
|
||||||
sc, _ := newTestCacheWithFakeUpstream(t, f, "1MB", "0")
|
|
||||||
sc.ResetMetrics()
|
|
||||||
|
|
||||||
rec1 := steamGet(t, sc, "/depot/gone/chunk")
|
|
||||||
if rec1.Code != http.StatusNotFound {
|
|
||||||
t.Fatalf("first request: expected 404, got %d body=%q", rec1.Code, rec1.Body.String())
|
|
||||||
}
|
|
||||||
if n := upstreamHits.Load(); n != 1 {
|
|
||||||
t.Fatalf("first request: expected 1 upstream hit, got %d", n)
|
|
||||||
}
|
|
||||||
|
|
||||||
rec2 := steamGet(t, sc, "/depot/gone/chunk")
|
|
||||||
if rec2.Code != http.StatusNotFound {
|
|
||||||
t.Fatalf("second request: expected 404, got %d", rec2.Code)
|
|
||||||
}
|
|
||||||
if n := upstreamHits.Load(); n != 1 {
|
|
||||||
t.Fatalf("repeated miss must not re-hit upstream within TTL, got %d", n)
|
|
||||||
}
|
|
||||||
|
|
||||||
stats := sc.GetMetrics()
|
|
||||||
if stats.NegativeCacheHits < 1 {
|
|
||||||
t.Errorf("expected NegativeCacheHits >= 1, got %d", stats.NegativeCacheHits)
|
|
||||||
}
|
|
||||||
if stats.CacheHits < 1 {
|
|
||||||
t.Errorf("negative hit should also count as cache_hits, got %d", stats.CacheHits)
|
|
||||||
}
|
|
||||||
if stats.UpstreamErrors != 1 {
|
|
||||||
t.Errorf("first 404 should count upstream error once, got %d", stats.UpstreamErrors)
|
|
||||||
}
|
|
||||||
|
|
||||||
mrec := httptest.NewRecorder()
|
|
||||||
sc.ServeHTTP(mrec, httptest.NewRequest(http.MethodGet, "/metrics", nil))
|
|
||||||
if !bytes.Contains(mrec.Body.Bytes(), []byte("negative_cache_hits")) {
|
|
||||||
t.Errorf("/metrics missing negative_cache_hits:\n%s", mrec.Body.String())
|
|
||||||
}
|
|
||||||
if ct := mrec.Header().Get("Content-Type"); ct != "text/plain; version=0.0.4; charset=utf-8" {
|
|
||||||
t.Errorf("/metrics Content-Type=%q, want Prometheus text 0.0.4", ct)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNegativeCache410(t *testing.T) {
|
|
||||||
var upstreamHits atomic.Int64
|
|
||||||
f := func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
upstreamHits.Add(1)
|
|
||||||
w.WriteHeader(http.StatusGone)
|
|
||||||
}
|
|
||||||
sc, _ := newTestCacheWithFakeUpstream(t, f, "1MB", "0")
|
|
||||||
|
|
||||||
rec1 := steamGet(t, sc, "/depot/gone410/chunk")
|
|
||||||
if rec1.Code != http.StatusGone {
|
|
||||||
t.Fatalf("first request: expected 410, got %d", rec1.Code)
|
|
||||||
}
|
|
||||||
rec2 := steamGet(t, sc, "/depot/gone410/chunk")
|
|
||||||
if rec2.Code != http.StatusGone {
|
|
||||||
t.Fatalf("second request: expected 410, got %d", rec2.Code)
|
|
||||||
}
|
|
||||||
if n := upstreamHits.Load(); n != 1 {
|
|
||||||
t.Fatalf("410 negative cache should suppress second upstream hit, got %d", n)
|
|
||||||
}
|
|
||||||
if sc.GetMetrics().NegativeCacheHits < 1 {
|
|
||||||
t.Errorf("expected NegativeCacheHits >= 1 after cached 410, got %d", sc.GetMetrics().NegativeCacheHits)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNegativeCacheExpiredRefetch(t *testing.T) {
|
|
||||||
var upstreamHits atomic.Int64
|
|
||||||
f := func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
upstreamHits.Add(1)
|
|
||||||
http.Error(w, "not found", http.StatusNotFound)
|
|
||||||
}
|
|
||||||
s := httptest.NewServer(http.HandlerFunc(f))
|
|
||||||
t.Cleanup(s.Close)
|
|
||||||
|
|
||||||
sc, err := NewWithOptions(Options{
|
|
||||||
Address: "127.0.0.1:0",
|
|
||||||
MemorySize: "1MB",
|
|
||||||
DiskSize: "0",
|
|
||||||
DiskPath: t.TempDir(),
|
|
||||||
Upstream: s.URL,
|
|
||||||
MemoryGC: "lru",
|
|
||||||
DiskGC: "lru",
|
|
||||||
MaxConcurrentRequests: 10,
|
|
||||||
MaxRequestsPerClient: 5,
|
|
||||||
MaxObjectSize: "0",
|
|
||||||
NegativeTTL: "1s",
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("NewWithOptions: %v", err)
|
|
||||||
}
|
|
||||||
t.Cleanup(func() { sc.Shutdown() })
|
|
||||||
|
|
||||||
if rec := steamGet(t, sc, "/depot/ttl/chunk"); rec.Code != http.StatusNotFound {
|
|
||||||
t.Fatalf("first request: expected 404, got %d", rec.Code)
|
|
||||||
}
|
|
||||||
if n := upstreamHits.Load(); n != 1 {
|
|
||||||
t.Fatalf("first request: expected 1 upstream hit, got %d", n)
|
|
||||||
}
|
|
||||||
|
|
||||||
deadline := time.Now().Add(3 * time.Second)
|
|
||||||
for time.Now().Before(deadline) {
|
|
||||||
time.Sleep(50 * time.Millisecond)
|
|
||||||
rec := steamGet(t, sc, "/depot/ttl/chunk")
|
|
||||||
if rec.Code != http.StatusNotFound {
|
|
||||||
t.Fatalf("expected 404 after expiry poll, got %d", rec.Code)
|
|
||||||
}
|
|
||||||
if upstreamHits.Load() >= 2 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
t.Fatalf("expired negative entry did not re-fetch upstream; hits=%d", upstreamHits.Load())
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSerializeNegativeHeader(t *testing.T) {
|
|
||||||
raw := []byte("HTTP/1.1 404 Not Found\r\nContent-Type: text/plain\r\n\r\ngone")
|
|
||||||
pos, err := serializeRawResponse(raw)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("serialize positive: %v", err)
|
|
||||||
}
|
|
||||||
posFile, err := deserializeCacheFile(pos)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("deserialize positive: %v", err)
|
|
||||||
}
|
|
||||||
if posFile.ExpiresUnix != 0 {
|
|
||||||
t.Errorf("positive entry ExpiresUnix=%d, want 0", posFile.ExpiresUnix)
|
|
||||||
}
|
|
||||||
|
|
||||||
expires := time.Now().Add(5 * time.Minute).Unix()
|
|
||||||
neg, err := serializeCacheFile(raw, expires)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("serialize negative: %v", err)
|
|
||||||
}
|
|
||||||
negFile, err := deserializeCacheFile(neg)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("deserialize negative: %v", err)
|
|
||||||
}
|
|
||||||
if negFile.ExpiresUnix != expires {
|
|
||||||
t.Errorf("ExpiresUnix=%d, want %d", negFile.ExpiresUnix, expires)
|
|
||||||
}
|
|
||||||
if !bytes.Equal(negFile.Response, raw) {
|
|
||||||
t.Error("negative raw response not preserved")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNewInvalidNegativeTTL(t *testing.T) {
|
|
||||||
sc, err := New("127.0.0.1:0", "1MB", "0", t.TempDir(), "", "lru", "lru", 10, 5, "0", nil, "not-a-duration", "", 0)
|
|
||||||
if err == nil {
|
|
||||||
if sc != nil {
|
|
||||||
sc.Shutdown()
|
|
||||||
}
|
|
||||||
t.Fatal("expected error for invalid negative ttl")
|
|
||||||
}
|
|
||||||
if sc != nil {
|
|
||||||
t.Error("expected nil SteamCache on invalid negative ttl")
|
|
||||||
}
|
|
||||||
if !strings.Contains(err.Error(), "invalid negative ttl") {
|
|
||||||
t.Errorf("err %q missing invalid negative ttl", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWriteTextNegativeCacheHits(t *testing.T) {
|
|
||||||
body := []byte("ok")
|
|
||||||
f := func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
_, _ = w.Write(body)
|
|
||||||
}
|
|
||||||
sc, _ := newTestCacheWithFakeUpstream(t, f, "1MB", "0")
|
|
||||||
srv := newCacheServer(t, sc)
|
|
||||||
c := &http.Client{Timeout: 5 * time.Second}
|
|
||||||
|
|
||||||
req, err := http.NewRequest(http.MethodGet, srv.URL+"/metrics", nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("NewRequest: %v", err)
|
|
||||||
}
|
|
||||||
resp, err := c.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("GET /metrics: %v", err)
|
|
||||||
}
|
|
||||||
out, err := io.ReadAll(resp.Body)
|
|
||||||
_ = resp.Body.Close()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("read /metrics: %v", err)
|
|
||||||
}
|
|
||||||
if !bytes.Contains(out, []byte("negative_cache_hits 0\n")) {
|
|
||||||
t.Errorf("/metrics missing negative_cache_hits 0:\n%s", out)
|
|
||||||
}
|
|
||||||
if ct := resp.Header.Get("Content-Type"); ct != "text/plain; version=0.0.4; charset=utf-8" {
|
|
||||||
t.Errorf("/metrics Content-Type=%q, want Prometheus text 0.0.4", ct)
|
|
||||||
}
|
|
||||||
if !bytes.Contains(out, []byte("# HELP negative_cache_hits ")) {
|
|
||||||
t.Errorf("/metrics missing # HELP negative_cache_hits:\n%s", out)
|
|
||||||
}
|
|
||||||
if !bytes.Contains(out, []byte("# TYPE negative_cache_hits counter")) {
|
|
||||||
t.Errorf("/metrics missing # TYPE negative_cache_hits counter:\n%s", out)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -313,18 +313,6 @@ func TestRangeMetricsWriteText(t *testing.T) {
|
|||||||
if !strings.Contains(text, "range_upstream 1\n") {
|
if !strings.Contains(text, "range_upstream 1\n") {
|
||||||
t.Errorf("/metrics missing 'range_upstream 1' line:\n%s", text)
|
t.Errorf("/metrics missing 'range_upstream 1' line:\n%s", text)
|
||||||
}
|
}
|
||||||
if ct := resp.Header.Get("Content-Type"); ct != "text/plain; version=0.0.4; charset=utf-8" {
|
|
||||||
t.Errorf("/metrics Content-Type=%q, want Prometheus text 0.0.4", ct)
|
|
||||||
}
|
|
||||||
if !strings.Contains(text, "# HELP range_cache ") {
|
|
||||||
t.Errorf("/metrics missing # HELP range_cache:\n%s", text)
|
|
||||||
}
|
|
||||||
if !strings.Contains(text, "# TYPE range_cache counter") {
|
|
||||||
t.Errorf("/metrics missing # TYPE range_cache counter:\n%s", text)
|
|
||||||
}
|
|
||||||
if !strings.Contains(text, "# TYPE range_upstream counter") {
|
|
||||||
t.Errorf("/metrics missing # TYPE range_upstream counter:\n%s", text)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestStreamCachedResponseRange206 is a focused unit test for streamCachedResponse:
|
// TestStreamCachedResponseRange206 is a focused unit test for streamCachedResponse:
|
||||||
|
|||||||
@@ -55,17 +55,10 @@ type SteamCache struct {
|
|||||||
clientRateLimiter *clientRateLimiter
|
clientRateLimiter *clientRateLimiter
|
||||||
maxRequestsPerClient int64
|
maxRequestsPerClient int64
|
||||||
|
|
||||||
// Per-client uplink bandwidth shaping (see bandwidth.go); nil/disabled = unlimited
|
|
||||||
bandwidth *clientBandwidthLimiter
|
|
||||||
|
|
||||||
// Hardening config fields (plumbed)
|
// Hardening config fields (plumbed)
|
||||||
maxObjectSize int64
|
maxObjectSize int64
|
||||||
trustedProxies []string
|
trustedProxies []string
|
||||||
|
|
||||||
// Negative TTL for 404/410 depot objects stored in the same VFS cache.
|
|
||||||
// Zero disables storing negatives (client still receives the upstream status).
|
|
||||||
negativeTTL time.Duration
|
|
||||||
|
|
||||||
// Service management
|
// Service management
|
||||||
serviceManager *ServiceManager
|
serviceManager *ServiceManager
|
||||||
|
|
||||||
@@ -78,17 +71,14 @@ type SteamCache struct {
|
|||||||
processor *requestProcessor
|
processor *requestProcessor
|
||||||
}
|
}
|
||||||
|
|
||||||
// DefaultNegativeTTL is used when cache.negative_ttl / Options.NegativeTTL is empty.
|
|
||||||
const DefaultNegativeTTL = 5 * time.Minute
|
|
||||||
|
|
||||||
// New creates a new SteamCache instance.
|
// New creates a new SteamCache instance.
|
||||||
// Returns an error (instead of panicking) on invalid memorySize or diskSize strings.
|
// Returns an error (instead of panicking) on invalid memorySize or diskSize strings.
|
||||||
// Also validates maxObjectSize and accepts trustedProxies for X-Forwarded-For handling.
|
// Also validates maxObjectSize and accepts trustedProxies for X-Forwarded-For handling.
|
||||||
// Empty maxObjectSize or nil trustedProxies are normalized to safe defaults before parsing.
|
// Empty maxObjectSize or nil trustedProxies are normalized to safe defaults before parsing.
|
||||||
// negativeTTL is a Go duration string for 404/410 negative cache entries; empty means 5m.
|
|
||||||
// Callers must check the returned error.
|
// Callers must check the returned error.
|
||||||
|
// The two new positional parameters are a breaking change for direct importers of the simple constructor.
|
||||||
// Prefer NewWithOptions (or config file) for forward compatibility. See README migration notes.
|
// Prefer NewWithOptions (or config file) for forward compatibility. See README migration notes.
|
||||||
func New(address string, memorySize string, diskSize string, diskPath, upstream, memoryGC, diskGC string, maxConcurrentRequests int64, maxRequestsPerClient int64, maxObjectSize string, trustedProxies []string, negativeTTL string, uplinkBandwidth string, maxBytesPerClientPerSec int64) (*SteamCache, error) {
|
func New(address string, memorySize string, diskSize string, diskPath, upstream, memoryGC, diskGC string, maxConcurrentRequests int64, maxRequestsPerClient int64, maxObjectSize string, trustedProxies []string) (*SteamCache, error) {
|
||||||
memorysize, err := units.FromHumanSize(memorySize)
|
memorysize, err := units.FromHumanSize(memorySize)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("invalid memory size: %w", err)
|
return nil, fmt.Errorf("invalid memory size: %w", err)
|
||||||
@@ -112,22 +102,6 @@ func New(address string, memorySize string, diskSize string, diskPath, upstream,
|
|||||||
return nil, fmt.Errorf("invalid max object size: %w", err)
|
return nil, fmt.Errorf("invalid max object size: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
negTTL, err := parseNegativeTTL(negativeTTL)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
var uplinkBytes int64
|
|
||||||
if uplinkBandwidth != "" && uplinkBandwidth != "0" {
|
|
||||||
uplinkBytes, err = units.FromHumanSize(uplinkBandwidth)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("invalid uplink bandwidth: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if maxBytesPerClientPerSec < 0 {
|
|
||||||
return nil, fmt.Errorf("negative max_bytes_per_client_per_sec not allowed")
|
|
||||||
}
|
|
||||||
|
|
||||||
c := cache.New()
|
c := cache.New()
|
||||||
|
|
||||||
var m *memory.MemoryFS
|
var m *memory.MemoryFS
|
||||||
@@ -192,13 +166,11 @@ func New(address string, memorySize string, diskSize string, diskPath, upstream,
|
|||||||
requestSemaphore: semaphore.NewWeighted(maxConcurrentRequests),
|
requestSemaphore: semaphore.NewWeighted(maxConcurrentRequests),
|
||||||
clientRateLimiter: newClientRateLimiter(maxRequestsPerClient),
|
clientRateLimiter: newClientRateLimiter(maxRequestsPerClient),
|
||||||
maxRequestsPerClient: maxRequestsPerClient,
|
maxRequestsPerClient: maxRequestsPerClient,
|
||||||
bandwidth: newClientBandwidthLimiter(uplinkBytes, maxBytesPerClientPerSec),
|
|
||||||
shutdownCh: make(chan struct{}),
|
shutdownCh: make(chan struct{}),
|
||||||
|
|
||||||
// Hardening config plumbed
|
// Hardening config plumbed
|
||||||
maxObjectSize: maxObjBytes,
|
maxObjectSize: maxObjBytes,
|
||||||
trustedProxies: trustedProxies,
|
trustedProxies: trustedProxies,
|
||||||
negativeTTL: negTTL,
|
|
||||||
|
|
||||||
// Initialize service management
|
// Initialize service management
|
||||||
serviceManager: NewServiceManager(),
|
serviceManager: NewServiceManager(),
|
||||||
@@ -367,11 +339,6 @@ func (sc *SteamCache) Shutdown() {
|
|||||||
func (sc *SteamCache) GetMetrics() *metrics.Stats {
|
func (sc *SteamCache) GetMetrics() *metrics.Stats {
|
||||||
if sc.memory != nil {
|
if sc.memory != nil {
|
||||||
sc.metrics.SetMemoryCacheSize(sc.memory.Size())
|
sc.metrics.SetMemoryCacheSize(sc.memory.Size())
|
||||||
sc.metrics.SetMemoryCacheCapacity(sc.memory.Capacity())
|
|
||||||
}
|
|
||||||
if sc.disk != nil {
|
|
||||||
// Capacity() is a plain config field — safe to read even while disk attach is pending.
|
|
||||||
sc.metrics.SetDiskCacheCapacity(sc.disk.Capacity())
|
|
||||||
}
|
}
|
||||||
// Skip disk.Size() while attach pending — Size() blocks on initDone and would hang /metrics.
|
// Skip disk.Size() while attach pending — Size() blocks on initDone and would hang /metrics.
|
||||||
if sc.disk != nil && sc.metrics.GetDiskTierReady() == 1 {
|
if sc.disk != nil && sc.metrics.GetDiskTierReady() == 1 {
|
||||||
@@ -385,26 +352,6 @@ func (sc *SteamCache) ResetMetrics() {
|
|||||||
sc.metrics.Reset()
|
sc.metrics.Reset()
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseNegativeTTL parses a Go duration string for 404/410 negative cache entries.
|
|
||||||
// Empty means DefaultNegativeTTL. Zero disables storing negatives.
|
|
||||||
func parseNegativeTTL(s string) (time.Duration, error) {
|
|
||||||
if s == "" {
|
|
||||||
return DefaultNegativeTTL, nil
|
|
||||||
}
|
|
||||||
d, err := time.ParseDuration(s)
|
|
||||||
if err != nil {
|
|
||||||
return 0, fmt.Errorf("invalid negative ttl: %w", err)
|
|
||||||
}
|
|
||||||
if d < 0 {
|
|
||||||
return 0, fmt.Errorf("invalid negative ttl: negative duration")
|
|
||||||
}
|
|
||||||
return d, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func isDefinitiveGone(statusCode int) bool {
|
|
||||||
return statusCode == http.StatusNotFound || statusCode == http.StatusGone
|
|
||||||
}
|
|
||||||
|
|
||||||
// newHTTPTransport returns a tuned http.Transport for upstream fetches.
|
// newHTTPTransport returns a tuned http.Transport for upstream fetches.
|
||||||
// Extracted to shrink New (Phase 3).
|
// Extracted to shrink New (Phase 3).
|
||||||
func newHTTPTransport() *http.Transport {
|
func newHTTPTransport() *http.Transport {
|
||||||
|
|||||||
+13
-163
@@ -28,7 +28,7 @@ import (
|
|||||||
func TestCaching(t *testing.T) {
|
func TestCaching(t *testing.T) {
|
||||||
td := t.TempDir()
|
td := t.TempDir()
|
||||||
|
|
||||||
sc, err := New("localhost:8080", "1G", "1G", td, "", "lru", "lru", 200, 5, "0", nil, "", "", 0)
|
sc, err := New("localhost:8080", "1G", "1G", td, "", "lru", "lru", 200, 5, "0", nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to create SteamCache: %v", err)
|
t.Fatalf("failed to create SteamCache: %v", err)
|
||||||
}
|
}
|
||||||
@@ -133,7 +133,7 @@ func TestCaching(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCacheMissAndHit(t *testing.T) {
|
func TestCacheMissAndHit(t *testing.T) {
|
||||||
sc, err := New("localhost:8080", "1MB", "1G", t.TempDir(), "", "lru", "lru", 200, 5, "0", nil, "", "", 0)
|
sc, err := New("localhost:8080", "1MB", "1G", t.TempDir(), "", "lru", "lru", 200, 5, "0", nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to create SteamCache: %v", err)
|
t.Fatalf("failed to create SteamCache: %v", err)
|
||||||
}
|
}
|
||||||
@@ -376,7 +376,7 @@ func TestServiceManagerExpandability(t *testing.T) {
|
|||||||
// Removed hash calculation tests since we switched to lightweight validation
|
// Removed hash calculation tests since we switched to lightweight validation
|
||||||
|
|
||||||
func TestSteamKeySharding(t *testing.T) {
|
func TestSteamKeySharding(t *testing.T) {
|
||||||
sc, err := New("localhost:8080", "1MB", "1G", t.TempDir(), "", "lru", "lru", 200, 5, "0", nil, "", "", 0)
|
sc, err := New("localhost:8080", "1MB", "1G", t.TempDir(), "", "lru", "lru", 200, 5, "0", nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to create SteamCache: %v", err)
|
t.Fatalf("failed to create SteamCache: %v", err)
|
||||||
}
|
}
|
||||||
@@ -483,7 +483,7 @@ func TestErrorTypes(t *testing.T) {
|
|||||||
// TestMetrics tests the metrics functionality
|
// TestMetrics tests the metrics functionality
|
||||||
func TestMetrics(t *testing.T) {
|
func TestMetrics(t *testing.T) {
|
||||||
td := t.TempDir()
|
td := t.TempDir()
|
||||||
sc, err := New("localhost:8080", "1G", "1G", td, "", "lru", "lru", 200, 5, "0", nil, "", "", 0)
|
sc, err := New("localhost:8080", "1G", "1G", td, "", "lru", "lru", 200, 5, "0", nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to create SteamCache: %v", err)
|
t.Fatalf("failed to create SteamCache: %v", err)
|
||||||
}
|
}
|
||||||
@@ -502,7 +502,6 @@ func TestMetrics(t *testing.T) {
|
|||||||
sc.metrics.IncrementTotalRequests()
|
sc.metrics.IncrementTotalRequests()
|
||||||
sc.metrics.IncrementCacheHits()
|
sc.metrics.IncrementCacheHits()
|
||||||
sc.metrics.IncrementCacheMisses()
|
sc.metrics.IncrementCacheMisses()
|
||||||
sc.metrics.IncrementNegativeCacheHits()
|
|
||||||
sc.metrics.AddBytesServed(1024)
|
sc.metrics.AddBytesServed(1024)
|
||||||
sc.metrics.IncrementServiceRequests("steam")
|
sc.metrics.IncrementServiceRequests("steam")
|
||||||
|
|
||||||
@@ -516,9 +515,6 @@ func TestMetrics(t *testing.T) {
|
|||||||
if stats.CacheMisses != 1 {
|
if stats.CacheMisses != 1 {
|
||||||
t.Error("Cache misses should be 1")
|
t.Error("Cache misses should be 1")
|
||||||
}
|
}
|
||||||
if stats.NegativeCacheHits != 1 {
|
|
||||||
t.Error("Negative cache hits should be 1")
|
|
||||||
}
|
|
||||||
if stats.TotalBytesServed != 1024 {
|
if stats.TotalBytesServed != 1024 {
|
||||||
t.Error("Total bytes served should be 1024")
|
t.Error("Total bytes served should be 1024")
|
||||||
}
|
}
|
||||||
@@ -546,9 +542,6 @@ func TestMetrics(t *testing.T) {
|
|||||||
if stats.CacheHits != 0 {
|
if stats.CacheHits != 0 {
|
||||||
t.Error("After reset, cache hits should be 0")
|
t.Error("After reset, cache hits should be 0")
|
||||||
}
|
}
|
||||||
if stats.NegativeCacheHits != 0 {
|
|
||||||
t.Error("After reset, negative cache hits should be 0")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Phase 3: exercise newly exported WriteText (cheap coverage for promotion)
|
// Phase 3: exercise newly exported WriteText (cheap coverage for promotion)
|
||||||
rec := httptest.NewRecorder()
|
rec := httptest.NewRecorder()
|
||||||
@@ -559,15 +552,6 @@ func TestMetrics(t *testing.T) {
|
|||||||
if !bytes.Contains(rec.Body.Bytes(), []byte("total_requests")) {
|
if !bytes.Contains(rec.Body.Bytes(), []byte("total_requests")) {
|
||||||
t.Error("WriteText output missing expected key")
|
t.Error("WriteText output missing expected key")
|
||||||
}
|
}
|
||||||
if !bytes.Contains(rec.Body.Bytes(), []byte("negative_cache_hits")) {
|
|
||||||
t.Error("WriteText output missing negative_cache_hits")
|
|
||||||
}
|
|
||||||
if !bytes.Contains(rec.Body.Bytes(), []byte("# HELP total_requests")) {
|
|
||||||
t.Error("WriteText output missing # HELP total_requests")
|
|
||||||
}
|
|
||||||
if !bytes.Contains(rec.Body.Bytes(), []byte("# TYPE total_requests counter")) {
|
|
||||||
t.Error("WriteText output missing # TYPE total_requests counter")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Removed old TestKeyGeneration - replaced with TestURLHashing that uses SHA256
|
// Removed old TestKeyGeneration - replaced with TestURLHashing that uses SHA256
|
||||||
@@ -587,7 +571,7 @@ func newTestCacheWithFakeUpstream(t *testing.T, h http.HandlerFunc, mem, disk st
|
|||||||
s := httptest.NewServer(h)
|
s := httptest.NewServer(h)
|
||||||
t.Cleanup(s.Close)
|
t.Cleanup(s.Close)
|
||||||
d := t.TempDir()
|
d := t.TempDir()
|
||||||
sc, err := New("127.0.0.1:0", mem, disk, d, s.URL, "lru", "lru", 200, 10, "0", nil, "", "", 0)
|
sc, err := New("127.0.0.1:0", mem, disk, d, s.URL, "lru", "lru", 200, 10, "0", nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to create SteamCache: %v", err)
|
t.Fatalf("failed to create SteamCache: %v", err)
|
||||||
}
|
}
|
||||||
@@ -749,7 +733,7 @@ func TestErrorMetrics(t *testing.T) {
|
|||||||
// Cover 503 capacity path + accounting skew: force Acquire err via canceled ctx.
|
// Cover 503 capacity path + accounting skew: force Acquire err via canceled ctx.
|
||||||
// Asserts Errors+RateLimited inc, Total unchanged (per documented design in code comment).
|
// Asserts Errors+RateLimited inc, Total unchanged (per documented design in code comment).
|
||||||
tdCap := t.TempDir()
|
tdCap := t.TempDir()
|
||||||
scCap, err := New("127.0.0.1:0", "1MB", "0", tdCap, "", "lru", "lru", 200, 5, "0", nil, "", "", 0)
|
scCap, err := New("127.0.0.1:0", "1MB", "0", tdCap, "", "lru", "lru", 200, 5, "0", nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("cap sc: %v", err)
|
t.Fatalf("cap sc: %v", err)
|
||||||
}
|
}
|
||||||
@@ -813,7 +797,7 @@ func TestErrorMetrics(t *testing.T) {
|
|||||||
func TestExpandedErrorMetrics(t *testing.T) {
|
func TestExpandedErrorMetrics(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
td := t.TempDir()
|
td := t.TempDir()
|
||||||
sc, err := New("localhost:0", "1MB", "0", td, "", "lru", "lru", 10, 5, "0", nil, "", "", 0)
|
sc, err := New("localhost:0", "1MB", "0", td, "", "lru", "lru", 10, 5, "0", nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("create: %v", err)
|
t.Fatalf("create: %v", err)
|
||||||
}
|
}
|
||||||
@@ -903,7 +887,7 @@ func TestNewInvalidSizes(t *testing.T) {
|
|||||||
}
|
}
|
||||||
for _, c := range cases {
|
for _, c := range cases {
|
||||||
t.Run(c.mem+"_"+c.disk, func(t *testing.T) {
|
t.Run(c.mem+"_"+c.disk, func(t *testing.T) {
|
||||||
sc, err := New("127.0.0.1:0", c.mem, c.disk, t.TempDir(), "", "lru", "lru", 10, 5, c.maxobj, nil, "", "", 0)
|
sc, err := New("127.0.0.1:0", c.mem, c.disk, t.TempDir(), "", "lru", "lru", 10, 5, c.maxobj, nil)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected error for bad size, got nil")
|
t.Fatal("expected error for bad size, got nil")
|
||||||
}
|
}
|
||||||
@@ -924,7 +908,7 @@ func TestNewRunShutdownHygiene(t *testing.T) {
|
|||||||
t.Skip("skips Run hygiene in -short per existing pattern")
|
t.Skip("skips Run hygiene in -short per existing pattern")
|
||||||
}
|
}
|
||||||
d := t.TempDir()
|
d := t.TempDir()
|
||||||
sc, err := New("127.0.0.1:0", "1MB", "0", d, "", "lru", "lru", 10, 5, "0", nil, "", "", 0)
|
sc, err := New("127.0.0.1:0", "1MB", "0", d, "", "lru", "lru", 10, 5, "0", nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("new: %v", err)
|
t.Fatalf("new: %v", err)
|
||||||
}
|
}
|
||||||
@@ -1073,7 +1057,7 @@ func TestDiskOnlyDelayedAttach(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// mem=0, disk>0 -> pure disk delayed path (go func)
|
// mem=0, disk>0 -> pure disk delayed path (go func)
|
||||||
sc, err := New("localhost:0", "0", "10MB", diskPath, "", "lru", "lru", 10, 1, "0", nil, "", "", 0)
|
sc, err := New("localhost:0", "0", "10MB", diskPath, "", "lru", "lru", 10, 1, "0", nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("New disk-only: %v", err)
|
t.Fatalf("New disk-only: %v", err)
|
||||||
}
|
}
|
||||||
@@ -1134,18 +1118,12 @@ func TestDiskOnlyDelayedAttach(t *testing.T) {
|
|||||||
if !bytes.Contains(rec.Body.Bytes(), []byte("capacity_pressure_events")) {
|
if !bytes.Contains(rec.Body.Bytes(), []byte("capacity_pressure_events")) {
|
||||||
t.Errorf("WriteText output missing capacity_pressure_events: %q", rec.Body.String())
|
t.Errorf("WriteText output missing capacity_pressure_events: %q", rec.Body.String())
|
||||||
}
|
}
|
||||||
if !bytes.Contains(rec.Body.Bytes(), []byte("# TYPE disk_tier_ready gauge")) {
|
|
||||||
t.Errorf("WriteText output missing # TYPE disk_tier_ready gauge: %q", rec.Body.String())
|
|
||||||
}
|
|
||||||
if !bytes.Contains(rec.Body.Bytes(), []byte("# TYPE capacity_pressure_events counter")) {
|
|
||||||
t.Errorf("WriteText output missing # TYPE capacity_pressure_events counter: %q", rec.Body.String())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestDiskTierSignalMemoryOnly covers memory-only mode: DiskTierReady=1 (N/A, not
|
// TestDiskTierSignalMemoryOnly covers memory-only mode: DiskTierReady=1 (N/A, not
|
||||||
// waiting on disk attach) and heartbeat header X-SteamCache-Disk-Tier: disabled.
|
// waiting on disk attach) and heartbeat header X-SteamCache-Disk-Tier: disabled.
|
||||||
func TestDiskTierSignalMemoryOnly(t *testing.T) {
|
func TestDiskTierSignalMemoryOnly(t *testing.T) {
|
||||||
sc, err := New("127.0.0.1:0", "1MB", "0", t.TempDir(), "", "lru", "lru", 10, 5, "0", nil, "", "", 0)
|
sc, err := New("127.0.0.1:0", "1MB", "0", t.TempDir(), "", "lru", "lru", 10, 5, "0", nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("New memory-only: %v", err)
|
t.Fatalf("New memory-only: %v", err)
|
||||||
}
|
}
|
||||||
@@ -1189,7 +1167,7 @@ func TestDiskTierSignalMixedPendingReady(t *testing.T) {
|
|||||||
disk.ClearInitHold(diskPath)
|
disk.ClearInitHold(diskPath)
|
||||||
})
|
})
|
||||||
|
|
||||||
sc, err := New("127.0.0.1:0", "1MB", "10MB", diskPath, "", "lru", "lru", 10, 1, "0", nil, "", "", 0)
|
sc, err := New("127.0.0.1:0", "1MB", "10MB", diskPath, "", "lru", "lru", 10, 1, "0", nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("New mixed: %v", err)
|
t.Fatalf("New mixed: %v", err)
|
||||||
}
|
}
|
||||||
@@ -1341,7 +1319,7 @@ func TestHostAllowedForDirectFetch(t *testing.T) {
|
|||||||
|
|
||||||
func TestDirectFetchRejectsNonSteamHost(t *testing.T) {
|
func TestDirectFetchRejectsNonSteamHost(t *testing.T) {
|
||||||
td := t.TempDir()
|
td := t.TempDir()
|
||||||
sc, err := New("127.0.0.1:0", "1MB", "0", td, "", "lru", "lru", 200, 5, "0", nil, "", "", 0)
|
sc, err := New("127.0.0.1:0", "1MB", "0", td, "", "lru", "lru", 200, 5, "0", nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("New: %v", err)
|
t.Fatalf("New: %v", err)
|
||||||
}
|
}
|
||||||
@@ -1497,131 +1475,3 @@ func TestCacheKeySharedAcrossCDNHostAliases(t *testing.T) {
|
|||||||
t.Errorf("upstream fetched %d times across host aliases, want 1", got)
|
t.Errorf("upstream fetched %d times across host aliases, want 1", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestGetMetricsCapacityGauges covers the tier-occupancy gauges: GetMetrics
|
|
||||||
// sets memory/disk capacity from the configured sizes, and WriteText emits
|
|
||||||
// memory_cache_capacity / disk_cache_capacity / disk_cache_full_ratio. During a
|
|
||||||
// pending disk attach, GetMetrics must return quickly (no disk.Size() call) and
|
|
||||||
// still report the configured disk capacity.
|
|
||||||
func TestGetMetricsCapacityGauges(t *testing.T) {
|
|
||||||
t.Run("memory-only", func(t *testing.T) {
|
|
||||||
sc, err := New("127.0.0.1:0", "1MB", "0", t.TempDir(), "", "lru", "lru", 10, 5, "0", nil, "", "", 0)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("New memory-only: %v", err)
|
|
||||||
}
|
|
||||||
t.Cleanup(func() { sc.Shutdown() })
|
|
||||||
|
|
||||||
st := sc.GetMetrics()
|
|
||||||
if st.MemoryCacheCapacity != 1000000 {
|
|
||||||
t.Errorf("MemoryCacheCapacity=%d, want 1000000 (configured 1MB)", st.MemoryCacheCapacity)
|
|
||||||
}
|
|
||||||
if st.DiskCacheCapacity != 0 {
|
|
||||||
t.Errorf("DiskCacheCapacity=%d, want 0 (no disk configured)", st.DiskCacheCapacity)
|
|
||||||
}
|
|
||||||
if st.DiskCacheFullRatio != 0 {
|
|
||||||
t.Errorf("DiskCacheFullRatio=%v, want 0 (no disk)", st.DiskCacheFullRatio)
|
|
||||||
}
|
|
||||||
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
metrics.WriteText(rec, sc.GetMetrics())
|
|
||||||
body := rec.Body.String()
|
|
||||||
if !strings.Contains(body, "memory_cache_capacity 1000000\n") {
|
|
||||||
t.Errorf("WriteText missing memory_cache_capacity 1000000: %q", body)
|
|
||||||
}
|
|
||||||
if !strings.Contains(body, "disk_cache_capacity 0\n") {
|
|
||||||
t.Errorf("WriteText missing disk_cache_capacity 0: %q", body)
|
|
||||||
}
|
|
||||||
if !strings.Contains(body, "# TYPE disk_cache_full_ratio gauge") {
|
|
||||||
t.Errorf("WriteText missing # TYPE disk_cache_full_ratio gauge: %q", body)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("mixed pending attach reports capacity without Size", func(t *testing.T) {
|
|
||||||
td := t.TempDir()
|
|
||||||
diskPath := filepath.Join(td, "disk")
|
|
||||||
if err := os.MkdirAll(diskPath, 0755); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
hold := make(chan struct{})
|
|
||||||
var holdOnce sync.Once
|
|
||||||
closeHold := func() { holdOnce.Do(func() { close(hold) }) }
|
|
||||||
disk.RegisterInitHold(diskPath, hold)
|
|
||||||
t.Cleanup(func() {
|
|
||||||
closeHold()
|
|
||||||
disk.ClearInitHold(diskPath)
|
|
||||||
})
|
|
||||||
|
|
||||||
sc, err := New("127.0.0.1:0", "1MB", "10MB", diskPath, "", "lru", "lru", 10, 1, "0", nil, "", "", 0)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("New mixed: %v", err)
|
|
||||||
}
|
|
||||||
t.Cleanup(func() { sc.Shutdown() })
|
|
||||||
t.Cleanup(closeHold) // before Shutdown: attach is blocked in Size() until the hold closes
|
|
||||||
|
|
||||||
// Pending window is held open; if GetMetrics called disk.Size() it would
|
|
||||||
// block on the barrier, so a bounded wait proves non-blocking behavior.
|
|
||||||
done := make(chan *metrics.Stats, 1)
|
|
||||||
go func() { done <- sc.GetMetrics() }()
|
|
||||||
select {
|
|
||||||
case st := <-done:
|
|
||||||
if got := st.DiskTierReady; got != 0 {
|
|
||||||
t.Fatalf("immediate DiskTierReady=%d, want 0 (pending)", got)
|
|
||||||
}
|
|
||||||
if st.DiskCacheCapacity != 10000000 {
|
|
||||||
t.Errorf("pending DiskCacheCapacity=%d, want 10000000 (configured 10MB)", st.DiskCacheCapacity)
|
|
||||||
}
|
|
||||||
if st.MemoryCacheCapacity != 1000000 {
|
|
||||||
t.Errorf("pending MemoryCacheCapacity=%d, want 1000000 (configured 1MB)", st.MemoryCacheCapacity)
|
|
||||||
}
|
|
||||||
if st.DiskCacheFullRatio != 0 {
|
|
||||||
t.Errorf("pending DiskCacheFullRatio=%v, want 0 (size not reported while pending)", st.DiskCacheFullRatio)
|
|
||||||
}
|
|
||||||
case <-time.After(2 * time.Second):
|
|
||||||
t.Fatal("GetMetrics blocked during pending attach (must not call disk.Size())")
|
|
||||||
}
|
|
||||||
|
|
||||||
closeHold()
|
|
||||||
_ = sc.disk.Size()
|
|
||||||
deadline := time.Now().Add(2 * time.Second)
|
|
||||||
for time.Now().Before(deadline) {
|
|
||||||
if sc.GetMetrics().DiskTierReady == 1 {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
time.Sleep(1 * time.Millisecond)
|
|
||||||
}
|
|
||||||
if got := sc.GetMetrics().DiskTierReady; got != 1 {
|
|
||||||
t.Fatalf("DiskTierReady=%d after barrier, want 1 (ready)", got)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Post-attach writes prefer the slow (disk) tier, so a write produces a
|
|
||||||
// non-zero disk size and hence a non-zero occupancy ratio.
|
|
||||||
w, err := sc.vfs.Create("occupancy-key", 128)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Create failed after attach: %v", err)
|
|
||||||
}
|
|
||||||
if _, err := w.Write(make([]byte, 128)); err != nil {
|
|
||||||
t.Fatalf("Write failed: %v", err)
|
|
||||||
}
|
|
||||||
if err := w.Close(); err != nil {
|
|
||||||
t.Fatalf("Close failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
st := sc.GetMetrics()
|
|
||||||
if st.DiskCacheCapacity != 10000000 {
|
|
||||||
t.Errorf("post-attach DiskCacheCapacity=%d, want 10000000", st.DiskCacheCapacity)
|
|
||||||
}
|
|
||||||
if st.DiskCacheSize <= 0 {
|
|
||||||
t.Errorf("post-attach DiskCacheSize=%d, want > 0 after a write", st.DiskCacheSize)
|
|
||||||
}
|
|
||||||
if st.DiskCacheFullRatio <= 0 || st.DiskCacheFullRatio > 1 {
|
|
||||||
t.Errorf("post-attach DiskCacheFullRatio=%v, want in (0,1]", st.DiskCacheFullRatio)
|
|
||||||
}
|
|
||||||
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
metrics.WriteText(rec, st)
|
|
||||||
body := rec.Body.String()
|
|
||||||
if !strings.Contains(body, "disk_cache_capacity 10000000\n") {
|
|
||||||
t.Errorf("WriteText missing disk_cache_capacity 10000000: %q", body)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user