Compare commits

..

8 Commits

Author SHA1 Message Date
Blake 97af0f829b ops: Assert Host allowlist reject in validate-check
CI / vulncheck (pull_request) Successful in 16s
CI / check-and-test (pull_request) Successful in 53s
CI / vulncheck (push) Successful in 22s
CI / check-and-test (push) Successful in 1m0s
Release Tag / release (push) Successful in 15s
Empty-upstream Host allowlist is load-bearing: if that gate regresses,
the cache becomes an open LAN reverse proxy again. Unit tests already
cover hostAllowedForDirectFetch, but make validate-check did not probe
the live reject path.

Extend validate-check to GET a depot-like path with Host: evil.example
and a Steam User-Agent, requiring HTTP 400 Invalid URL. Document the
expected reject in README and the validate-config comment.

Fixes #37
2026-09-14 13:39:32 +00:00
pike 25622cbf25 ops: Fix goimports on fair-share bandwidth PR
CI / vulncheck (pull_request) Successful in 14s
CI / check-and-test (pull_request) Successful in 51s
CI / vulncheck (push) Successful in 14s
CI / check-and-test (push) Successful in 50s
Release Tag / release (push) Successful in 20s
2026-09-09 20:27:32 +00:00
pike b7710de0ca cache: Per-client fair-share bandwidth on table uplink
CI / vulncheck (pull_request) Successful in 20s
CI / check-and-test (pull_request) Failing after 21s
2026-09-09 20:22:23 +00:00
pike 50ca0a071c ops: Disk tier occupancy on /metrics
CI / vulncheck (pull_request) Successful in 19s
CI / check-and-test (pull_request) Successful in 47s
CI / vulncheck (push) Successful in 16s
CI / check-and-test (push) Successful in 46s
Release Tag / release (push) Successful in 14s
Operators could see attach-ready and capacity-pressure events but not how
full the configured disk (or memory) tier was without reading filesystems.
Expose size/capacity gauges and disk_cache_full_ratio next to disk_tier_ready.
Capacity is a config read, so it stays available while attach is pending.
2026-09-09 16:06:12 +00:00
linus a3ea4806a7 Merge pull request 'cache: Coalesce in-flight identical upstream fetches' (#52) from cache/coalesce-inflight-fetches into develop
CI / vulncheck (push) Successful in 16s
CI / check-and-test (push) Successful in 48s
Release Tag / release (push) Successful in 15s
cache: Coalesce in-flight identical upstream fetches

Fixes #35
2026-09-08 15:19:26 -05:00
ash ea195993de cache: Coalesce in-flight identical upstream fetches
CI / vulncheck (pull_request) Successful in 14s
CI / check-and-test (pull_request) Successful in 48s
In-flight coalescing already dedups identical misses, but acceptance
still lacked a gated test (without a hold, waiters can become sequential
HITs after the first fill) and the Quick check metrics table omitted
cache_coalesced.

Add steamcache/coalesce_test.go: N concurrent identical GETs share one
upstream fill (leader MISS, waiters HIT-COALESCED) and a 5xx sibling.
Document cache_coalesced next to the other hit/miss fields.

Fixes #35
2026-09-08 20:00:53 +00:00
eva 7c34ff4538 Merge pull request 'metrics: Prometheus text exposition for /metrics' (#51) from metrics/prometheus-exposition into main
CI / vulncheck (push) Successful in 15s
CI / check-and-test (push) Successful in 41s
Release Tag / release (push) Successful in 16s
metrics: Prometheus text exposition for /metrics (#51)

Closes #49
2026-09-08 14:57:22 -05:00
pike dd72668c2d metrics: Prometheus text exposition for /metrics
CI / vulncheck (pull_request) Successful in 14s
CI / check-and-test (pull_request) Successful in 42s
/metrics was Prometheus-ish name/value text with a custom banner and
text/plain Content-Type, so strict scrapers could fail.

Emit Prometheus text 0.0.4 (# HELP, # TYPE, counter|gauge) with stable
metric names, and set Content-Type to text/plain; version=0.0.4; charset=utf-8.
2026-09-08 19:53:58 +00:00
18 changed files with 1105 additions and 54 deletions
+22 -3
View File
@@ -62,7 +62,7 @@ validate run-validation: build clean-disk ## Start steamcache2 on :80 with small
fi; \
exec "$$BINARY" --config docs/examples/validate-config.yaml --log-level info
validate-check: ## Curl local /metrics (full dump + hit/miss + upstream/write/rate fields) and /lancache-heartbeat (default :80)
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 "=== http://localhost/metrics ==="
@metrics=$$(curl -sf --max-time 5 http://localhost/metrics) || { \
echo "ERROR: could not fetch http://localhost/metrics"; \
@@ -84,7 +84,26 @@ 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 "ERROR: expected HTTP 204 and X-LanCache-Processed-By on /lancache-heartbeat"; \
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)
@echo "Looking for steamcache2 processes on common validation ports (80 is primary)..."
@@ -133,7 +152,7 @@ help: ## Show this help message
@echo " clean-disk Remove disk cache"
@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-check Curl local /metrics (full dump + hit/miss + upstream/write/rate fields) and /lancache-heartbeat (default :80)"
@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 " setcap Explicitly set cap on current build (for port 80 use outside validate)"
@echo " validate-kill Kill leftover steamcache2 processes (safer)"
@echo " prefill Download latest SteamPrefill into bin/steam-prefill/SteamPrefill (gitignored)"
+20 -2
View File
@@ -76,23 +76,29 @@ curl -s http://localhost/metrics
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`. 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`. 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:
| Field | Meaning |
| --- | --- |
| `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 |
| `memory_cache_hits` / `disk_cache_hits` | Which tier served the hits |
| `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) |
| `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). |
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.
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`).
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.
@@ -109,6 +115,8 @@ 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.
`/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).
### Development Workflow
@@ -176,7 +184,7 @@ curl -s http://localhost/metrics
curl -s -i http://localhost/lancache-heartbeat
```
`make validate-check` prints the full `/metrics` dump, highlights hit/miss fields, and curls `/lancache-heartbeat`. Look for:
`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:
- 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
- Zero unexpected `errors`, and quiet `upstream_errors` / `cache_write_failures` / `rate_limited`
@@ -228,6 +236,10 @@ While most configuration is done via the YAML file, some runtime options are sti
./steamcache2 --max-concurrent-requests 8
./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
./steamcache2 --help
```
@@ -244,6 +256,11 @@ listen_address: :80
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
# 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:
# Memory cache settings
@@ -306,6 +323,7 @@ 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.
- 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` 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.
- `/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.
+14 -2
View File
@@ -20,8 +20,10 @@ var (
logLevel string
logFormat string
maxConcurrentRequests int64
maxRequestsPerClient int64
maxConcurrentRequests int64
maxRequestsPerClient int64
uplinkBandwidth string
maxBytesPerClientPerSec int64
)
var rootCmd = &cobra.Command{
@@ -107,6 +109,12 @@ var rootCmd = &cobra.Command{
if maxRequestsPerClient > 0 {
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)
if err := cfg.Validate(); err != nil {
@@ -130,6 +138,8 @@ var rootCmd = &cobra.Command{
cfg.MaxObjectSize,
cfg.TrustedProxies,
cfg.Cache.NegativeTTL,
cfg.UplinkBandwidth,
cfg.MaxBytesPerClientPerSec,
)
if err != nil {
logger.Logger.Error().
@@ -170,4 +180,6 @@ func init() {
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().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)")
}
+13
View File
@@ -19,6 +19,11 @@ type Config struct {
MaxConcurrentRequests int64 `yaml:"max_concurrent_requests" default:"200"`
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)
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.
@@ -186,6 +191,14 @@ func (c Config) Validate() error {
if c.MaxRequestsPerClient < 0 {
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 != "" {
switch c.Cache.Memory.GCAlgorithm {
+68
View File
@@ -211,3 +211,71 @@ 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)
}
+2
View File
@@ -28,6 +28,7 @@
#
# After the benchmark run, inspect with:
# 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:
# curl -s http://localhost/metrics
# curl -s -i http://localhost/lancache-heartbeat # GET, not HEAD
@@ -38,6 +39,7 @@
listen_address: :80
max_concurrent_requests: 1000
# uplink_bandwidth / max_bytes_per_client_per_sec default off (unlimited)
max_requests_per_client: 10
max_object_size: "0" # unlimited for validation (real Steam files can be large)
+1
View File
@@ -9,6 +9,7 @@ require (
github.com/spf13/cobra v1.8.1
golang.org/x/sync v0.16.0
golang.org/x/sys v0.12.0
golang.org/x/time v0.16.0
gopkg.in/yaml.v3 v3.0.1
)
+2
View File
@@ -27,6 +27,8 @@ 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.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o=
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/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+158
View File
@@ -0,0 +1,158 @@
// 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
}
+128
View File
@@ -0,0 +1,128 @@
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)
}
}
+199
View File
@@ -0,0 +1,199 @@
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)
}
}
+16 -3
View File
@@ -38,10 +38,14 @@ type Options struct {
// 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) {
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)
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)
}
// handleSpecialEndpoints handles non-content paths (health, heartbeat, metrics) and
@@ -76,9 +80,9 @@ func (sc *SteamCache) handleSpecialEndpoints(w http.ResponseWriter, r *http.Requ
}
if r.URL.String() == "/metrics" {
// Return metrics in a simple text format
// Prometheus text exposition format 0.0.4
stats := sc.GetMetrics()
w.Header().Set("Content-Type", "text/plain")
w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
w.WriteHeader(http.StatusOK)
metrics.WriteText(w, stats)
return true
@@ -413,6 +417,15 @@ func (sc *SteamCache) ServeHTTP(w http.ResponseWriter, r *http.Request) {
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
if service, isSupported := sc.detectService(r); isSupported {
// Cache key is the path only, never the Host: Steam rotates CDN hostnames
+86 -29
View File
@@ -32,6 +32,8 @@ type Metrics struct {
// Cache metrics
MemoryCacheSize int64
DiskCacheSize int64
MemoryCacheCapacity int64 // configured memory capacity (bytes)
DiskCacheCapacity int64 // configured disk capacity (bytes); 0 when no disk
MemoryCacheHits int64
DiskCacheHits int64
Promotions int64
@@ -137,6 +139,17 @@ func (m *Metrics) SetDiskCacheSize(size int64) {
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).
// Memory-only (no disk) also uses 1 — meaning "not waiting on disk attach". Reset does not clear this.
func (m *Metrics) SetDiskTierReady(ready int64) {
@@ -248,6 +261,11 @@ func (m *Metrics) GetStats() *Stats {
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{
TotalRequests: totalRequests,
CacheHits: cacheHits,
@@ -262,8 +280,11 @@ func (m *Metrics) GetStats() *Stats {
AvgResponseTime: avgResponseTime,
TotalBytesServed: atomic.LoadInt64(&m.TotalBytesServed),
TotalBytesSaved: atomic.LoadInt64(&m.TotalBytesSaved),
MemoryCacheSize: atomic.LoadInt64(&m.MemoryCacheSize),
DiskCacheSize: atomic.LoadInt64(&m.DiskCacheSize),
MemoryCacheSize: memoryCacheSize,
DiskCacheSize: diskCacheSize,
MemoryCacheCapacity: memoryCacheCapacity,
DiskCacheCapacity: diskCacheCapacity,
DiskCacheFullRatio: diskFullRatio(diskCacheSize, diskCacheCapacity),
DiskTierReady: atomic.LoadInt64(&m.DiskTierReady),
MemoryCacheHits: atomic.LoadInt64(&m.MemoryCacheHits),
DiskCacheHits: atomic.LoadInt64(&m.DiskCacheHits),
@@ -279,6 +300,19 @@ 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
func (m *Metrics) Reset() {
atomic.StoreInt64(&m.TotalRequests, 0)
@@ -330,6 +364,9 @@ type Stats struct {
MemoryCacheSize 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
MemoryCacheHits int64
DiskCacheHits int64
@@ -344,42 +381,62 @@ type Stats struct {
LastResetTime time.Time
}
// WriteText emits the Prometheus-style text metrics to the ResponseWriter.
// Promoted from internal handler per Phase 3 for better package ownership.
// WriteText emits Prometheus text exposition format 0.0.4 to the ResponseWriter.
// Each metric family is # HELP, then # TYPE, then one or more sample lines.
// 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
// read-only debug endpoint; client disconnects or write errors during metrics dump
// are not actionable (do not affect cache correctness or require retries).
func WriteText(w http.ResponseWriter, stats *Stats) {
_, _ = fmt.Fprintf(w, "# SteamCache2 Metrics\n")
_, _ = fmt.Fprintf(w, "total_requests %d\n", stats.TotalRequests)
_, _ = fmt.Fprintf(w, "cache_hits %d\n", stats.CacheHits)
_, _ = fmt.Fprintf(w, "cache_misses %d\n", stats.CacheMisses)
_, _ = fmt.Fprintf(w, "negative_cache_hits %d\n", stats.NegativeCacheHits)
_, _ = fmt.Fprintf(w, "cache_coalesced %d\n", stats.CacheCoalesced)
_, _ = fmt.Fprintf(w, "range_cache %d\n", stats.RangeCache)
_, _ = fmt.Fprintf(w, "range_upstream %d\n", stats.RangeUpstream)
_, _ = fmt.Fprintf(w, "errors %d\n", stats.Errors)
_, _ = fmt.Fprintf(w, "rate_limited %d\n", stats.RateLimited)
_, _ = fmt.Fprintf(w, "upstream_errors %d\n", stats.UpstreamErrors)
_, _ = fmt.Fprintf(w, "cache_write_failures %d\n", stats.CacheWriteFailures)
_, _ = fmt.Fprintf(w, "memory_cache_hits %d\n", stats.MemoryCacheHits)
_, _ = fmt.Fprintf(w, "disk_cache_hits %d\n", stats.DiskCacheHits)
_, _ = fmt.Fprintf(w, "promotions %d\n", stats.Promotions)
_, _ = fmt.Fprintf(w, "evictions %d\n", stats.Evictions)
_, _ = fmt.Fprintf(w, "capacity_pressure_events %d\n", stats.CapacityPressureEvents)
writeInt(w, "total_requests", "Total HTTP requests handled.", "counter", stats.TotalRequests)
writeInt(w, "cache_hits", "Requests served from cache.", "counter", stats.CacheHits)
writeInt(w, "cache_misses", "Requests not found in cache.", "counter", stats.CacheMisses)
writeInt(w, "negative_cache_hits", "404/410 served from a still-valid negative cache entry.", "counter", stats.NegativeCacheHits)
writeInt(w, "cache_coalesced", "Requests coalesced onto an in-flight upstream fetch.", "counter", stats.CacheCoalesced)
writeInt(w, "range_cache", "Range requests served as 206 from an already-cached object.", "counter", stats.RangeCache)
writeInt(w, "range_upstream", "Range requests that required an upstream fetch, served as 206.", "counter", stats.RangeUpstream)
writeInt(w, "errors", "Request errors.", "counter", stats.Errors)
writeInt(w, "rate_limited", "Requests rejected by rate limiting.", "counter", stats.RateLimited)
writeInt(w, "upstream_errors", "Errors talking to upstream.", "counter", stats.UpstreamErrors)
writeInt(w, "cache_write_failures", "Failures writing objects into cache.", "counter", stats.CacheWriteFailures)
writeInt(w, "memory_cache_hits", "Hits served from the memory tier.", "counter", stats.MemoryCacheHits)
writeInt(w, "disk_cache_hits", "Hits served from the disk tier.", "counter", stats.DiskCacheHits)
writeInt(w, "promotions", "Objects promoted from disk to memory.", "counter", stats.Promotions)
writeInt(w, "evictions", "Objects evicted from cache.", "counter", stats.Evictions)
writeInt(w, "capacity_pressure_events", "Soft eviction under the memory or disk cap, and/or disk ENOSPC.", "counter", stats.CapacityPressureEvents)
writeHelpType(w, "service_errors", "Errors attributed to a named service.", "counter")
for svc, cnt := range stats.ServiceErrors {
_, _ = 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 {
_, _ = 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)
_, _ = fmt.Fprintf(w, "memory_cache_size %d\n", stats.MemoryCacheSize)
_, _ = fmt.Fprintf(w, "disk_cache_size %d\n", stats.DiskCacheSize)
_, _ = fmt.Fprintf(w, "disk_tier_ready %d\n", stats.DiskTierReady)
_, _ = fmt.Fprintf(w, "uptime_seconds %.2f\n", stats.Uptime.Seconds())
writeFloat(w, "hit_rate", "Cache hits divided by total requests.", "gauge", "%.4f", stats.HitRate)
writeFloat(w, "avg_response_time_ms", "Average response time in milliseconds.", "gauge", "%.2f", float64(stats.AvgResponseTime.Nanoseconds())/1e6)
writeInt(w, "total_bytes_served", "Total bytes sent to clients.", "counter", stats.TotalBytesServed)
writeInt(w, "total_bytes_saved", "Bytes served from cache instead of being re-downloaded from upstream.", "counter", stats.TotalBytesSaved)
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)
}
+177
View File
@@ -4,7 +4,9 @@ import (
"bytes"
"errors"
"net/http/httptest"
"strings"
"testing"
"time"
)
func TestCapacityPressureEventsWriteTextAndReset(t *testing.T) {
@@ -47,6 +49,15 @@ func TestCapacityPressureEventsWriteTextAndReset(t *testing.T) {
if !bytes.Contains(body, []byte("evictions 2")) {
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()
st = m.GetStats()
@@ -61,3 +72,169 @@ func TestNoteSoftEvictionNilMetrics(t *testing.T) {
NoteSoftEviction(nil, "memory", 10)
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
}
+13 -1
View File
@@ -61,6 +61,9 @@ func TestNegativeCache404(t *testing.T) {
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) {
@@ -167,7 +170,7 @@ func TestSerializeNegativeHeader(t *testing.T) {
}
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")
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()
@@ -207,4 +210,13 @@ func TestWriteTextNegativeCacheHits(t *testing.T) {
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)
}
}
+12
View File
@@ -313,6 +313,18 @@ func TestRangeMetricsWriteText(t *testing.T) {
if !strings.Contains(text, "range_upstream 1\n") {
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:
+21 -1
View File
@@ -55,6 +55,9 @@ type SteamCache struct {
clientRateLimiter *clientRateLimiter
maxRequestsPerClient int64
// Per-client uplink bandwidth shaping (see bandwidth.go); nil/disabled = unlimited
bandwidth *clientBandwidthLimiter
// Hardening config fields (plumbed)
maxObjectSize int64
trustedProxies []string
@@ -85,7 +88,7 @@ const DefaultNegativeTTL = 5 * time.Minute
// negativeTTL is a Go duration string for 404/410 negative cache entries; empty means 5m.
// Callers must check the returned error.
// 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) (*SteamCache, error) {
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) {
memorysize, err := units.FromHumanSize(memorySize)
if err != nil {
return nil, fmt.Errorf("invalid memory size: %w", err)
@@ -114,6 +117,17 @@ func New(address string, memorySize string, diskSize string, diskPath, upstream,
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()
var m *memory.MemoryFS
@@ -178,6 +192,7 @@ func New(address string, memorySize string, diskSize string, diskPath, upstream,
requestSemaphore: semaphore.NewWeighted(maxConcurrentRequests),
clientRateLimiter: newClientRateLimiter(maxRequestsPerClient),
maxRequestsPerClient: maxRequestsPerClient,
bandwidth: newClientBandwidthLimiter(uplinkBytes, maxBytesPerClientPerSec),
shutdownCh: make(chan struct{}),
// Hardening config plumbed
@@ -352,6 +367,11 @@ func (sc *SteamCache) Shutdown() {
func (sc *SteamCache) GetMetrics() *metrics.Stats {
if sc.memory != nil {
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.
if sc.disk != nil && sc.metrics.GetDiskTierReady() == 1 {
+153 -13
View File
@@ -28,7 +28,7 @@ import (
func TestCaching(t *testing.T) {
td := t.TempDir()
sc, err := New("localhost:8080", "1G", "1G", td, "", "lru", "lru", 200, 5, "0", nil, "")
sc, err := New("localhost:8080", "1G", "1G", td, "", "lru", "lru", 200, 5, "0", nil, "", "", 0)
if err != nil {
t.Fatalf("failed to create SteamCache: %v", err)
}
@@ -133,7 +133,7 @@ func TestCaching(t *testing.T) {
}
func TestCacheMissAndHit(t *testing.T) {
sc, err := New("localhost:8080", "1MB", "1G", t.TempDir(), "", "lru", "lru", 200, 5, "0", nil, "")
sc, err := New("localhost:8080", "1MB", "1G", t.TempDir(), "", "lru", "lru", 200, 5, "0", nil, "", "", 0)
if err != nil {
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
func TestSteamKeySharding(t *testing.T) {
sc, err := New("localhost:8080", "1MB", "1G", t.TempDir(), "", "lru", "lru", 200, 5, "0", nil, "")
sc, err := New("localhost:8080", "1MB", "1G", t.TempDir(), "", "lru", "lru", 200, 5, "0", nil, "", "", 0)
if err != nil {
t.Fatalf("failed to create SteamCache: %v", err)
}
@@ -483,7 +483,7 @@ func TestErrorTypes(t *testing.T) {
// TestMetrics tests the metrics functionality
func TestMetrics(t *testing.T) {
td := t.TempDir()
sc, err := New("localhost:8080", "1G", "1G", td, "", "lru", "lru", 200, 5, "0", nil, "")
sc, err := New("localhost:8080", "1G", "1G", td, "", "lru", "lru", 200, 5, "0", nil, "", "", 0)
if err != nil {
t.Fatalf("failed to create SteamCache: %v", err)
}
@@ -562,6 +562,12 @@ func TestMetrics(t *testing.T) {
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
@@ -581,7 +587,7 @@ func newTestCacheWithFakeUpstream(t *testing.T, h http.HandlerFunc, mem, disk st
s := httptest.NewServer(h)
t.Cleanup(s.Close)
d := t.TempDir()
sc, err := New("127.0.0.1:0", mem, disk, d, s.URL, "lru", "lru", 200, 10, "0", nil, "")
sc, err := New("127.0.0.1:0", mem, disk, d, s.URL, "lru", "lru", 200, 10, "0", nil, "", "", 0)
if err != nil {
t.Fatalf("failed to create SteamCache: %v", err)
}
@@ -743,7 +749,7 @@ func TestErrorMetrics(t *testing.T) {
// Cover 503 capacity path + accounting skew: force Acquire err via canceled ctx.
// Asserts Errors+RateLimited inc, Total unchanged (per documented design in code comment).
tdCap := t.TempDir()
scCap, err := New("127.0.0.1:0", "1MB", "0", tdCap, "", "lru", "lru", 200, 5, "0", nil, "")
scCap, err := New("127.0.0.1:0", "1MB", "0", tdCap, "", "lru", "lru", 200, 5, "0", nil, "", "", 0)
if err != nil {
t.Fatalf("cap sc: %v", err)
}
@@ -807,7 +813,7 @@ func TestErrorMetrics(t *testing.T) {
func TestExpandedErrorMetrics(t *testing.T) {
t.Parallel()
td := t.TempDir()
sc, err := New("localhost:0", "1MB", "0", td, "", "lru", "lru", 10, 5, "0", nil, "")
sc, err := New("localhost:0", "1MB", "0", td, "", "lru", "lru", 10, 5, "0", nil, "", "", 0)
if err != nil {
t.Fatalf("create: %v", err)
}
@@ -897,7 +903,7 @@ func TestNewInvalidSizes(t *testing.T) {
}
for _, c := range cases {
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, "")
sc, err := New("127.0.0.1:0", c.mem, c.disk, t.TempDir(), "", "lru", "lru", 10, 5, c.maxobj, nil, "", "", 0)
if err == nil {
t.Fatal("expected error for bad size, got nil")
}
@@ -918,7 +924,7 @@ func TestNewRunShutdownHygiene(t *testing.T) {
t.Skip("skips Run hygiene in -short per existing pattern")
}
d := t.TempDir()
sc, err := New("127.0.0.1:0", "1MB", "0", d, "", "lru", "lru", 10, 5, "0", nil, "")
sc, err := New("127.0.0.1:0", "1MB", "0", d, "", "lru", "lru", 10, 5, "0", nil, "", "", 0)
if err != nil {
t.Fatalf("new: %v", err)
}
@@ -1067,7 +1073,7 @@ func TestDiskOnlyDelayedAttach(t *testing.T) {
})
// mem=0, disk>0 -> pure disk delayed path (go func)
sc, err := New("localhost:0", "0", "10MB", diskPath, "", "lru", "lru", 10, 1, "0", nil, "")
sc, err := New("localhost:0", "0", "10MB", diskPath, "", "lru", "lru", 10, 1, "0", nil, "", "", 0)
if err != nil {
t.Fatalf("New disk-only: %v", err)
}
@@ -1128,12 +1134,18 @@ func TestDiskOnlyDelayedAttach(t *testing.T) {
if !bytes.Contains(rec.Body.Bytes(), []byte("capacity_pressure_events")) {
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
// waiting on disk attach) and heartbeat header X-SteamCache-Disk-Tier: disabled.
func TestDiskTierSignalMemoryOnly(t *testing.T) {
sc, err := New("127.0.0.1:0", "1MB", "0", t.TempDir(), "", "lru", "lru", 10, 5, "0", nil, "")
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)
}
@@ -1177,7 +1189,7 @@ func TestDiskTierSignalMixedPendingReady(t *testing.T) {
disk.ClearInitHold(diskPath)
})
sc, err := New("127.0.0.1:0", "1MB", "10MB", diskPath, "", "lru", "lru", 10, 1, "0", nil, "")
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)
}
@@ -1329,7 +1341,7 @@ func TestHostAllowedForDirectFetch(t *testing.T) {
func TestDirectFetchRejectsNonSteamHost(t *testing.T) {
td := t.TempDir()
sc, err := New("127.0.0.1:0", "1MB", "0", td, "", "lru", "lru", 200, 5, "0", nil, "")
sc, err := New("127.0.0.1:0", "1MB", "0", td, "", "lru", "lru", 200, 5, "0", nil, "", "", 0)
if err != nil {
t.Fatalf("New: %v", err)
}
@@ -1485,3 +1497,131 @@ func TestCacheKeySharedAcrossCDNHostAliases(t *testing.T) {
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)
}
})
}