Compare commits
57 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 97af0f829b | |||
| 25622cbf25 | |||
| b7710de0ca | |||
| 50ca0a071c | |||
| a3ea4806a7 | |||
| ea195993de | |||
| 7c34ff4538 | |||
| dd72668c2d | |||
| 0db9943436 | |||
| e3b2b8de1e | |||
| fe04fb9fbb | |||
| de43a71929 | |||
| a0e929c525 | |||
| 036ea1ea7f | |||
| 8eb31143e5 | |||
| 337741e06e | |||
| 2c8ec276e7 | |||
| 144a68dcd7 | |||
| 8cebc1f96c | |||
| 12ea3ee4f6 | |||
| a500f51f17 | |||
| ac2d36f1ad | |||
| c43bfba568 | |||
| 3411defd51 | |||
| 71d5106777 | |||
| 8b1b229539 | |||
| ff1ab31327 | |||
| 85e14bc8af | |||
| 3f5175b482 | |||
| 04d1c6c368 | |||
| 523a9a4782 | |||
| 8e09c89e24 | |||
| a2ac13d317 | |||
| 5d006ac44f | |||
| acd006d4a2 | |||
| 30a695458e | |||
| e8bcf0ddbd | |||
| f497e71ef0 | |||
| 0198e8990b | |||
| 2a2cd8d393 | |||
| 81b3a7df53 | |||
| 8e8e877533 | |||
| d63d7b4d3c | |||
| 35d698a232 | |||
| 36b613b6cd | |||
| 79f02d9868 | |||
| 19497eba0c | |||
| c7a2312994 | |||
| fbb084d824 | |||
| e7d4a19c3f | |||
| 0c54ef3404 | |||
| 3d3c74fdb2 | |||
| 04f55535a5 | |||
| 05640bb549 | |||
| e4be82cddf | |||
| 60b2c3e514 | |||
| 099e5347d5 |
@@ -7,6 +7,8 @@ on:
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@main
|
||||
with:
|
||||
@@ -21,4 +23,6 @@ jobs:
|
||||
version: 'latest'
|
||||
args: release
|
||||
env:
|
||||
GITEA_TOKEN: ${{secrets.RELEASE_TOKEN}}
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
GORELEASER_FORCE_TOKEN: gitea
|
||||
|
||||
@@ -1,24 +1,38 @@
|
||||
name: PR Check
|
||||
name: CI
|
||||
on:
|
||||
- pull_request
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths-ignore:
|
||||
- '**.md'
|
||||
- 'CONTRIBUTING.md'
|
||||
|
||||
jobs:
|
||||
check-and-test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@main
|
||||
- uses: actions/setup-go@main
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: 'go.mod'
|
||||
- run: go mod tidy
|
||||
- run: go build ./...
|
||||
- run: go vet ./...
|
||||
- name: golangci-lint
|
||||
uses: golangci/golangci-lint-action@v4
|
||||
uses: golangci/golangci-lint-action@v8
|
||||
with:
|
||||
version: latest
|
||||
version: v2.13.2
|
||||
args: --timeout=5m
|
||||
- run: go test -race -v -shuffle=on -coverprofile=coverage.out -timeout=5m ./...
|
||||
- run: go tool cover -func=coverage.out | tail -10 # basic coverage report
|
||||
|
||||
vulncheck:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: 'go.mod'
|
||||
- run: go install golang.org/x/vuln/cmd/govulncheck@latest
|
||||
- run: govulncheck ./...
|
||||
- run: go test -race -v -shuffle=on -coverprofile=coverage.out -timeout=5m ./...
|
||||
- run: go tool cover -func=coverage.out | tail -10 # basic coverage report (P2-04)
|
||||
+74
-66
@@ -1,86 +1,94 @@
|
||||
# .golangci.yml - steamcache2 lint config
|
||||
# .golangci.yml - steamcache2 lint config (golangci-lint v2)
|
||||
# Philosophy: enable reasonable linters by default (golangci curated set + key additions)
|
||||
# then use most specific suppressions possible (source //nosec with justification,
|
||||
# _ = discard for errcheck on unavoidable client writes, narrow exclude-rules only for tests).
|
||||
# This makes remaining accepted issues visible and actionable in the code.
|
||||
# Run with: make lint (or golangci-lint run ./...)
|
||||
# Install: go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest
|
||||
version: "2"
|
||||
|
||||
run:
|
||||
timeout: 5m
|
||||
modules-download-mode: readonly
|
||||
|
||||
linters:
|
||||
# No disable-all: use golangci defaults (errcheck, govet, ineffassign, staticcheck, unused, gosimple, etc.)
|
||||
# No default: none — use golangci defaults (errcheck, govet, ineffassign, staticcheck, unused, etc.)
|
||||
# Explicitly enable the non-default linters we require for this LAN cache proxy.
|
||||
enable:
|
||||
- gosec # security checks (re-audited; see source //nosec for justified cases)
|
||||
- misspell # documentation hygiene
|
||||
- goimports # import formatting (enforced)
|
||||
# gofmt covered via linter or goimports; errcheck/govet etc. from defaults
|
||||
settings:
|
||||
errcheck:
|
||||
check-type-assertions: false
|
||||
check-blank: false
|
||||
# gosec: keep source-level //nosec for G104/G115/G301/G304/G306.
|
||||
# G704/G705 are new taint-analysis rules (SSRF/XSS) not present in v1.64.8;
|
||||
# a CDN cache proxy forwards upstream URLs and response bodies by design.
|
||||
gosec:
|
||||
excludes:
|
||||
- G704
|
||||
- G705
|
||||
# v1 staticcheck checks: ["all"] meant SA* only. v2 merged stylecheck (ST*)
|
||||
# and quickfix (QF*) into staticcheck; keep the previous SA*+gosimple set.
|
||||
staticcheck:
|
||||
checks:
|
||||
- all
|
||||
- "-ST*"
|
||||
- "-QF*"
|
||||
govet:
|
||||
enable-all: true
|
||||
disable:
|
||||
- fieldalignment # performance tuning not a priority for this proxy appliance
|
||||
- shadow # common idiomatic "err" redeclarations in error-handling chains (large ServeHTTP, root, parse funcs); enabling adds noise with no real bugs; would require scope refactor for little gain
|
||||
exclusions:
|
||||
generated: lax
|
||||
paths:
|
||||
- dist
|
||||
- bin
|
||||
rules:
|
||||
- path: _test\.go
|
||||
linters:
|
||||
- errcheck
|
||||
- gosec # tests often use weak patterns intentionally (e.g. error injection, temp files)
|
||||
# NOTE: narrow SA9003 exclude retained only for the one remaining intentional empty branch in test (best-effort status check; main assert is metrics side-effect).
|
||||
- path: steamcache/steamcache_test.go
|
||||
linters:
|
||||
- staticcheck
|
||||
text: "SA9003: empty branch"
|
||||
# Narrow gosec excludes for unavoidable classes after re-audit (LAN proxy threat model):
|
||||
# - G115: int64<->uint casts in eviction/GC math (all sizes positive, guarded by capacity checks; API uses uint for bytesNeeded)
|
||||
# - G304: path vars for Read/Open/Remove under trusted disk.root or user config file (sanitized keys, no traversal, no arbitrary inclusion from untrusted URLs)
|
||||
# G306 for config WriteFile kept as source //nosec (one site).
|
||||
# G301 fixed at source (0700 dirs). G104 addressed via errcheck fixes.
|
||||
- path: vfs/memory/memory.go
|
||||
linters:
|
||||
- gosec
|
||||
text: "G115"
|
||||
- path: vfs/disk/disk.go
|
||||
linters:
|
||||
- gosec
|
||||
text: "G115"
|
||||
- path: vfs/gc/gc.go
|
||||
linters:
|
||||
- gosec
|
||||
text: "G115"
|
||||
- path: config/config.go
|
||||
linters:
|
||||
- gosec
|
||||
text: "G304"
|
||||
- path: vfs/disk/disk.go
|
||||
linters:
|
||||
- gosec
|
||||
text: "G304"
|
||||
|
||||
linters-settings:
|
||||
errcheck:
|
||||
check-type-assertions: false
|
||||
check-blank: false
|
||||
gosec:
|
||||
# Broad global excludes removed (G104/G115/G301/G304/G306).
|
||||
# - G301 addressed by switching cache MkdirAll to 0700 (least privilege for CDN content).
|
||||
# - Remaining justified cases documented with precise //nosec (or #nosec) + comments at the call sites.
|
||||
# - G104 largely eliminated by errcheck + explicit _ = handling (or defer wrappers).
|
||||
staticcheck:
|
||||
checks: ["all"] # SA1019 exclusion removed (no deprecated API usages in tree)
|
||||
govet:
|
||||
enable-all: true
|
||||
disable:
|
||||
- fieldalignment # performance tuning not a priority for this proxy appliance
|
||||
- shadow # common idiomatic "err" redeclarations in error-handling chains (large ServeHTTP, root, parse funcs); enabling adds noise with no real bugs; would require scope refactor for little gain
|
||||
|
||||
# Old global errcheck disable + aspirational "re-enable after refactors" comments deleted.
|
||||
# errcheck is now on via defaults. Unavoidable cases handled at source with _ = or (rarely) narrow rules.
|
||||
formatters:
|
||||
enable:
|
||||
- goimports
|
||||
exclusions:
|
||||
generated: lax
|
||||
paths:
|
||||
- dist
|
||||
- bin
|
||||
|
||||
issues:
|
||||
max-issues-per-linter: 0
|
||||
max-same-issues: 0
|
||||
exclude-use-default: false
|
||||
exclude-dirs:
|
||||
- dist
|
||||
- bin
|
||||
exclude-rules:
|
||||
- path: _test\.go
|
||||
linters:
|
||||
- errcheck
|
||||
- gosec # tests often use weak patterns intentionally (e.g. error injection, temp files)
|
||||
# NOTE: narrow SA9003 exclude retained only for the one remaining intentional empty branch in test (best-effort status check; main assert is metrics side-effect).
|
||||
# The config one was a truly redundant check (already errored above); deleted surgically in Fix Round 1 (Issue 1), eliminating its exclude-rule.
|
||||
- path: steamcache/steamcache_test.go
|
||||
linters:
|
||||
- staticcheck
|
||||
text: "SA9003: empty branch"
|
||||
# Narrow gosec excludes for unavoidable classes after re-audit (LAN proxy threat model):
|
||||
# - G115: int64<->uint casts in eviction/GC math (all sizes positive, guarded by capacity checks; API uses uint for bytesNeeded)
|
||||
# - G304: path vars for Read/Open/Remove under trusted disk.root or user config file (sanitized keys, no traversal, no arbitrary inclusion from untrusted URLs)
|
||||
# G306 for config WriteFile kept as source //nosec (one site).
|
||||
# G301 fixed at source (0700 dirs). G104 addressed via errcheck fixes.
|
||||
- path: vfs/memory/memory.go
|
||||
linters:
|
||||
- gosec
|
||||
text: "G115"
|
||||
- path: vfs/disk/disk.go
|
||||
linters:
|
||||
- gosec
|
||||
text: "G115"
|
||||
- path: vfs/gc/gc.go
|
||||
linters:
|
||||
- gosec
|
||||
text: "G115"
|
||||
- path: config/config.go
|
||||
linters:
|
||||
- gosec
|
||||
text: "G304"
|
||||
- path: vfs/disk/disk.go
|
||||
linters:
|
||||
- gosec
|
||||
text: "G304"
|
||||
# Predictive/* rules deleted: vfs/predictive/ removed in commit 0dbb2e0; rules were stale/dead.
|
||||
# All other suppressions use source-level //nosec (gosec) or _= (errcheck) for precision and visibility.
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
# Contributing
|
||||
|
||||
## Propose changes
|
||||
|
||||
Open a pull request against `develop`. Keep the default branch for releases and
|
||||
stable tips; land work on `develop` first.
|
||||
|
||||
Point at an existing issue when one fits. Prefer a short issue that states the
|
||||
symptom or request before a large PR.
|
||||
|
||||
## Commits
|
||||
|
||||
Subject form:
|
||||
|
||||
```
|
||||
area: Imperative summary
|
||||
```
|
||||
|
||||
- **Area** is a real package, directory, or subsystem token (`ci:`, `docs:`,
|
||||
Go package name). Not a lone filename.
|
||||
- **Imperative** mood: Fix, Add, Remove — not "Fixed" or "This patch…".
|
||||
- No trailing period. Aim ≤ ~70–75 characters for the whole subject.
|
||||
- Not conventional-commits (`feat:` / `fix:` / `chore:` as types).
|
||||
|
||||
Body explains **why**. Establish the problem, then say what you are doing.
|
||||
One logical change per commit; split fix and cleanup.
|
||||
|
||||
## Pull requests
|
||||
|
||||
Title matches the primary commit subject.
|
||||
|
||||
- **What** changed
|
||||
- **Why** (problem and impact)
|
||||
- **Test** (concrete steps; "CI green" alone is weak)
|
||||
|
||||
## Issues and closing
|
||||
|
||||
Cite leftover issues by **full URL**. Gitea closes issues when `#N` appears in
|
||||
merge text, so do not put `#N` in the merge message unless that issue is actually
|
||||
done. Use `Fixes #N` / `Closes #N` only when the leftover work is finished.
|
||||
@@ -4,21 +4,21 @@ run: ## Run the application (cross-platform; uses go run for dev on Linux/macOS/
|
||||
run-debug: ## Run the application with debug logging (cross-platform)
|
||||
@go run . --log-level debug
|
||||
|
||||
build: deps ## Build a snapshot of the application for the current platform (uses -short for fast feedback)
|
||||
build: deps lint ## Build a snapshot of the application for the current platform (uses -short for fast feedback)
|
||||
@go test -short -v ./...
|
||||
@goreleaser build --single-target --snapshot --clean
|
||||
|
||||
test: deps ## Run all tests
|
||||
test: deps lint ## Run all tests
|
||||
@go test -shuffle=on -timeout=5m -v ./...
|
||||
|
||||
test-race: deps ## Run all tests with the race detector
|
||||
test-race: deps lint ## Run all tests with the race detector
|
||||
@go test -race -shuffle=on -timeout=5m -v ./...
|
||||
|
||||
lint: deps check-review-labels ## Run golangci-lint + review label hygiene check
|
||||
@golangci-lint run ./...
|
||||
|
||||
check-review-labels: ## Fail if temporary review labels (P0-01, T1, I3, R2, etc.) are found in source
|
||||
@! grep -rnE '\b[A-Z][0-9][^a-zA-Z]' --include='*.go' . 2>/dev/null | grep -v 'G[0-9]\{3\}' || (echo "Error: Found temporary review labels (P*, T*, I*, etc.) in source. See AGENTS.md for the rule." && exit 1)
|
||||
@! grep -rnE '\b[A-Z][0-9][^a-zA-Z]' --include='*.go' --include='*.md' --include='*.yaml' --include='*.sh' --exclude='AGENTS.md' . 2>/dev/null | grep -v 'G[0-9]\{3\}' || (echo "Error: Found temporary review labels (P*, T*, I*, etc.) in source. See AGENTS.md for the rule." && exit 1)
|
||||
|
||||
deps: ## Download dependencies
|
||||
@go mod tidy
|
||||
@@ -26,6 +26,9 @@ deps: ## Download dependencies
|
||||
clean: ## Remove build artifacts and test cache
|
||||
@rm -rf bin/ dist/ *.test coverage.out steamcache2
|
||||
|
||||
clean-disk: ## Remove disk cache
|
||||
@rm -rf validate-disk/
|
||||
|
||||
bench: deps ## Run all benchmarks (MemoryFS + DiskFS variants, including all eviction strategies)
|
||||
@echo "Running MemoryFS benchmarks..."
|
||||
@go test -bench=. -benchmem -run=^$ -benchtime=1s ./vfs/memory
|
||||
@@ -38,7 +41,7 @@ setcap: build ## Explicitly set cap_net_bind_service on the (just-built) binary
|
||||
@sudo setcap 'cap_net_bind_service=+ep' dist/default_linux_amd64_v1/steamcache2
|
||||
@echo "Done. You should now be able to run 'make run-validation' as your normal user (no root)."
|
||||
|
||||
validate run-validation: build ## Start steamcache2 on :80 with small test caches (foreground)
|
||||
validate run-validation: build clean-disk ## Start steamcache2 on :80 with small test caches (foreground)
|
||||
@echo "=== Starting steamcache2 in validation mode ==="
|
||||
@echo "Port 80 + small memory/disk caches (for exercising disk tier, GC, etc.)"
|
||||
@echo "Press Ctrl-C to stop the server."
|
||||
@@ -59,16 +62,48 @@ validate run-validation: build ## Start steamcache2 on :80 with small test cache
|
||||
fi; \
|
||||
exec "$$BINARY" --config docs/examples/validate-config.yaml --log-level info
|
||||
|
||||
validate-check: ## Quick post-benchmark sanity check against a running steamcache2 (default port 80, override with PORT=xxxx)
|
||||
@echo "=== steamcache2 Full Function Validation Report ==="
|
||||
@PORT="$${PORT:-80}"; \
|
||||
echo "Server: http://localhost:$$PORT"; \
|
||||
curl -s --max-time 5 "http://localhost:$$PORT/metrics" || echo "(could not reach /metrics on port $$PORT - is the server running?)"; \
|
||||
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"; \
|
||||
echo "Is steamcache2 running on the default listen address :80?"; \
|
||||
exit 1; \
|
||||
}; \
|
||||
printf '%s\n' "$$metrics"; \
|
||||
echo ""; \
|
||||
echo "Tip: also inspect recent server logs for errors, coalesced hits, and disk activity."
|
||||
|
||||
prefill: ## Download latest SteamPrefill into bin/steam-prefill/SteamPrefill (gitignored)
|
||||
@./scripts/download-prefill.sh
|
||||
echo "=== hit/miss + upstream/write/rate fields ==="; \
|
||||
printf '%s\n' "$$metrics" | grep -E '^(total_requests|cache_hits|cache_misses|hit_rate|memory_cache_hits|disk_cache_hits|errors|upstream_errors|cache_write_failures|rate_limited) ' || true; \
|
||||
echo ""; \
|
||||
echo "=== http://localhost/lancache-heartbeat (GET; expect 204 + X-LanCache-Processed-By: SteamCache2) ==="; \
|
||||
hb=$$(curl -sD - -o /dev/null --max-time 5 http://localhost/lancache-heartbeat) || { \
|
||||
echo "ERROR: could not fetch http://localhost/lancache-heartbeat"; \
|
||||
echo "Is steamcache2 running on the default listen address :80?"; \
|
||||
exit 1; \
|
||||
}; \
|
||||
printf '%s\n' "$$hb"; \
|
||||
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)..."
|
||||
@@ -95,6 +130,11 @@ validate-kill: ## Kill leftover steamcache2 processes (safer, checks process nam
|
||||
done
|
||||
@echo "Validation server cleanup complete."
|
||||
|
||||
prefill: ## Download latest SteamPrefill into bin/steam-prefill/SteamPrefill (gitignored)
|
||||
@./scripts/download-prefill.sh
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
help: ## Show this help message
|
||||
@@ -109,9 +149,10 @@ help: ## Show this help message
|
||||
@echo " check-review-labels Fail on temporary review labels (P*, T*, I*, R*, etc.)"
|
||||
@echo " deps Download dependencies"
|
||||
@echo " clean Remove build/test artifacts"
|
||||
@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)"
|
||||
@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 " setcap Explicitly set cap on current build (for port 80 use outside validate)"
|
||||
@echo " validate-check Quick /metrics report after running a workload"
|
||||
@echo " validate-kill Kill leftover steamcache2 processes (safer)"
|
||||
@echo " prefill Download latest SteamPrefill into bin/steam-prefill/ (for use with run-validation)"
|
||||
@echo " prefill Download latest SteamPrefill into bin/steam-prefill/SteamPrefill (gitignored)"
|
||||
|
||||
@@ -10,8 +10,8 @@ SteamCache2 is a blazing fast download cache for Steam, designed to reduce bandw
|
||||
- Reduces bandwidth usage
|
||||
- Easy to set up and configure aside from dns stuff to trick Steam into using it
|
||||
- Supports multiple clients
|
||||
- **NEW:** YAML configuration system with automatic config generation
|
||||
- **NEW:** Simple Makefile for development workflow
|
||||
- YAML configuration with automatic config generation on first run
|
||||
- Makefile for development and validation workflows
|
||||
- Cross-platform builds (Linux, macOS, Windows)
|
||||
|
||||
## Quick Start
|
||||
@@ -34,7 +34,10 @@ SteamCache2 is a blazing fast download cache for Steam, designed to reduce bandw
|
||||
|
||||
The application will automatically create a `config.yaml` file with default settings and exit, allowing you to customize it.
|
||||
|
||||
3. **Edit the configuration** (`config.yaml`):
|
||||
3. **Edit the configuration** (`config.yaml`) for a real Steam front-door:
|
||||
|
||||
Leave `upstream` empty. With an empty upstream, steamcache2 fetches from the request `Host` (the same pattern SteamPrefill / real Steam clients use when DNS points them at your cache). Do **not** paste a fake host like `https://steam.cdn.com` — that is not a Steam CDN and will not put you in front of Steam.
|
||||
|
||||
```yaml
|
||||
listen_address: :80
|
||||
cache:
|
||||
@@ -45,14 +48,77 @@ SteamCache2 is a blazing fast download cache for Steam, designed to reduce bandw
|
||||
size: 10GB
|
||||
path: ./disk
|
||||
gc_algorithm: hybrid
|
||||
upstream: "https://steam.cdn.com" # Set your upstream server
|
||||
# Empty upstream = use the client Host as the origin (Steam CDN names only).
|
||||
upstream: ""
|
||||
```
|
||||
|
||||
4. **Run the application again:**
|
||||
4. **Point Steam (or SteamPrefill) at this cache** before you expect hits:
|
||||
|
||||
- **LAN DNS:** resolve `lancache.steamcontent.com` (and other Steam content names your clients use) to this server's LAN IP.
|
||||
- **Single Windows PC:** add a hosts override — see [Windows Hosts File Override](#windows-hosts-file-override) (`<cache-ip> lancache.steamcontent.com`).
|
||||
- Restart Steam (or your prefill tool) after DNS/hosts changes.
|
||||
|
||||
Empty-upstream direct fetch only allows Steam CDN host suffixes (`steamcontent.com`, `steampowered.com`, `steamstatic.com`). Literal IPs and unrelated hosts are rejected.
|
||||
|
||||
5. **Run the application again:**
|
||||
```bash
|
||||
make run # or ./steamcache2
|
||||
```
|
||||
|
||||
### Quick check: is it caching?
|
||||
|
||||
After steamcache2 is running (default `listen_address: :80`) and has seen a little Steam traffic — a game download, a short SteamPrefill pass, or any cacheable request — confirm hits vs misses from the existing endpoints. You do not need a full benchmark or log diving.
|
||||
|
||||
```bash
|
||||
make validate-check
|
||||
# or, manually:
|
||||
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`. 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.
|
||||
|
||||
To confirm the process is up (HTTP 204 and `X-LanCache-Processed-By: SteamCache2`):
|
||||
|
||||
```bash
|
||||
curl -s -i http://localhost/lancache-heartbeat
|
||||
```
|
||||
|
||||
Use GET (`curl -i`), not HEAD (`curl -I`): the server only accepts GET.
|
||||
|
||||
Heartbeat also returns `X-SteamCache-Disk-Tier: pending|ready|disabled` (`disabled` = memory-only / no disk; `pending`/`ready` = disk configured attach state).
|
||||
|
||||
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
|
||||
|
||||
Use `make` for the majority of common development tasks. The Makefile handles running tests, linting, hygiene checks, building, running the application, and other routine boilerplate work.
|
||||
@@ -70,26 +136,7 @@ This gives you:
|
||||
- Excellent `benchmark setup` / `benchmark run` workflow with warmup, randomization, and mixed chunk sizes
|
||||
- The ability to validate a **just-built binary** end-to-end (caching, coalescing, Range support, memory+disk tiers, GC/eviction, metrics, special endpoints, startup validation, etc.)
|
||||
|
||||
#### Quick Start
|
||||
|
||||
```bash
|
||||
# 1. Build the binary (this also runs short tests)
|
||||
make build
|
||||
|
||||
# 2. Start a validation-oriented instance (small caches so disk tier + GC get exercised)
|
||||
# Uses port 80 by default; the script will automatically set the needed
|
||||
# capability on the binary via sudo setcap if it is missing.
|
||||
./scripts/validate-with-prefill.sh
|
||||
|
||||
# 3. In another terminal (or on another machine), create a workload once if you haven't already,
|
||||
# then drive it through your local steamcache2.
|
||||
# Note: when using a non-80 port you may need to give SteamPrefill the full address.
|
||||
./scripts/validate-with-prefill.sh # (shows the exact commands with the correct port)
|
||||
```
|
||||
|
||||
When the benchmark finishes, press Ctrl-C in the first terminal to cleanly stop the server.
|
||||
|
||||
#### Simple validation server (recommended for manual testing)
|
||||
#### Validation server (recommended)
|
||||
|
||||
For easy validation with external tools (SteamPrefill, etc.), use:
|
||||
|
||||
@@ -115,13 +162,16 @@ When the server is running, point your external SteamPrefill (or other load gene
|
||||
./SteamPrefill benchmark run ...
|
||||
```
|
||||
|
||||
When finished, you can get a quick metrics summary with:
|
||||
When finished, you can get a quick metrics + heartbeat report with:
|
||||
|
||||
```bash
|
||||
make validate-check
|
||||
# or, manually:
|
||||
curl -s http://localhost/metrics
|
||||
curl -s -i http://localhost/lancache-heartbeat
|
||||
```
|
||||
|
||||
This is the recommended simple workflow. No automatic downloading or running of external tools.
|
||||
See [Quick check: is it caching?](#quick-check-is-it-caching) for which fields to read. This is the recommended simple workflow. No automatic downloading or running of external tools.
|
||||
|
||||
#### Inspecting the Result
|
||||
|
||||
@@ -129,25 +179,28 @@ After a benchmark run you can ask for a quick report:
|
||||
|
||||
```bash
|
||||
make validate-check
|
||||
# or manually:
|
||||
# or, manually:
|
||||
curl -s http://localhost/metrics
|
||||
curl -s -i http://localhost/lancache-heartbeat
|
||||
```
|
||||
|
||||
Look for:
|
||||
- High cache hit rate after the warmup pass
|
||||
`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
|
||||
- Zero unexpected `errors`, and quiet `upstream_errors` / `cache_write_failures` / `rate_limited`
|
||||
|
||||
Heartbeat should be HTTP 204 with `X-LanCache-Processed-By: SteamCache2`. Use GET (`curl -i`), not HEAD (`curl -I`).
|
||||
|
||||
#### The Validation Config
|
||||
|
||||
The script uses [docs/examples/validate-config.yaml](docs/examples/validate-config.yaml). It enables both memory and disk tiers at modest sizes (128 MB / 512 MB) with conservative concurrency. Edit or copy it if you need larger caches for bigger workloads.
|
||||
The recommended validation config is at [docs/examples/validate-config.yaml](docs/examples/validate-config.yaml). It enables both memory and disk tiers at modest sizes (128 MB / 512 MB) with conservative concurrency. Edit or copy it if you need larger caches for bigger workloads.
|
||||
|
||||
#### What Gets Validated
|
||||
|
||||
Running a realistic SteamPrefill benchmark workload through a built steamcache2 exercises the complete public surface that matters for production use:
|
||||
- Steam User-Agent detection and depot/manifest/chunk URL patterns
|
||||
- Full MISS → cache write → HIT (and HIT-COALESCED) paths
|
||||
- Range request handling from cached full responses
|
||||
- Range request handling from cached full responses (local 206 on HIT via `range_cache`; MISS fetches the full object then serves the requested slice as 206 via `range_upstream`)
|
||||
- Request coalescing under concurrent load
|
||||
- Memory tier + disk tier interaction (including async disk attach)
|
||||
- Garbage collection and eviction under pressure
|
||||
@@ -179,8 +232,13 @@ While most configuration is done via the YAML file, some runtime options are sti
|
||||
# Set logging level
|
||||
./steamcache2 --log-level debug --log-format json
|
||||
|
||||
# Set number of worker threads
|
||||
./steamcache2 --threads 8
|
||||
# Override concurrency from the CLI (0 = use config.yaml)
|
||||
./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
|
||||
@@ -194,10 +252,15 @@ SteamCache2 uses a YAML configuration file (`config.yaml`) for all settings. Her
|
||||
# Server configuration
|
||||
listen_address: :80
|
||||
|
||||
# P1 hardening (see Security Hardening section)
|
||||
# Hardening (see Security Hardening section)
|
||||
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
|
||||
@@ -216,13 +279,18 @@ cache:
|
||||
# Garbage collection algorithm
|
||||
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
|
||||
# The upstream server to proxy requests to
|
||||
upstream: "https://steam.cdn.com"
|
||||
# Leave empty to fetch from the request Host (Steam CDN names only).
|
||||
# Set only when chaining caches (table RAM cache -> room disk cache).
|
||||
upstream: ""
|
||||
```
|
||||
|
||||
#### Startup Validation
|
||||
As of P0, `steamcache2` performs strict validation on startup (after loading config + CLI overrides, before creating the cache). Invalid configs cause immediate clean failure (no default written, no panic):
|
||||
`steamcache2` performs strict validation on startup (after loading config + CLI overrides, before creating the cache). Invalid configs cause immediate clean failure (no default written, no panic):
|
||||
|
||||
- Negative `max_concurrent_requests` / `max_requests_per_client`: "negative concurrency not allowed"
|
||||
- Invalid `gc_algorithm` (memory): "invalid memory gc algorithm: badvalue"
|
||||
@@ -236,15 +304,15 @@ Error: Invalid configuration: invalid memory gc algorithm: foo. Please fix the c
|
||||
|
||||
See `config.Validate()` and `steamcache.New` error paths. This ensures the LAN appliance fails fast on misconfig.
|
||||
|
||||
#### Security Hardening (P1)
|
||||
- `max_object_size` (default "0" = unlimited): set e.g. "256MB" or "512MB" to reject oversized upstream responses with HTTP 413 before buffering/ReadAll. Prevents OOM DoS from large or malicious responses (P1-01). Large legitimate Steam files still served if under limit.
|
||||
- `trusted_proxies`: CIDR list (default empty). When empty (safe default), X-Forwarded-For and client IP spoofing are ignored for rate limiting — always uses `r.RemoteAddr` only. When set (e.g. your reverse proxy CIDR), uses correct "rightmost untrusted" extraction. Prevents bypass of `max_requests_per_client` (P1-02). Documented for LAN proxy setups only.
|
||||
- These + P0 validation make steamcache2 safe-by-default for LAN exposure.
|
||||
#### Security Hardening
|
||||
- `max_object_size` (default "0" = unlimited): set e.g. "256MB" or "512MB" to reject oversized upstream responses with HTTP 413 before buffering/ReadAll. Prevents OOM DoS from large or malicious responses. Large legitimate Steam files still served if under limit.
|
||||
- `trusted_proxies`: CIDR list (default empty). When empty (safe default), X-Forwarded-For and client IP spoofing are ignored for rate limiting — always uses `r.RemoteAddr` only. When set (e.g. your reverse proxy CIDR), uses correct "rightmost untrusted" extraction. Prevents bypass of `max_requests_per_client`. Documented for LAN proxy setups only.
|
||||
- These + the startup validation make steamcache2 safe-by-default for LAN exposure.
|
||||
|
||||
#### Migration / Breaking Changes (P1)
|
||||
- `New()` public signature gained 2 required trailing params (`maxObjectSize`, `trustedProxies`). Direct callers (rare; most use config or NewWithOptions) must update.
|
||||
#### 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.
|
||||
- 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).
|
||||
- No behavior change for existing configs (defaults preserve prior semantics; `cache.negative_ttl` defaults to 5m).
|
||||
|
||||
#### 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).
|
||||
@@ -253,6 +321,11 @@ See `config.Validate()` and `steamcache.New` error paths. This ensures the LAN a
|
||||
- The explicit startup guard (reduce size if pre-existing on-disk > cap) runs as the literal last step of bg init, before the barrier opens.
|
||||
- Add a note for operators: very large disk caches (tens/hundreds GB with millions files) may show extended "memory-only or no-cache" behavior at startup (seconds to minutes depending on storage speed); this is by design for responsiveness.
|
||||
- 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.
|
||||
|
||||
#### Garbage Collection Algorithms
|
||||
|
||||
@@ -330,7 +403,7 @@ This will direct any requests to `lancache.steamcontent.com` to your SteamCache2
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Go 1.19 or later
|
||||
- Go 1.27.0 or later
|
||||
- Make (optional, but recommended)
|
||||
|
||||
### Build Commands
|
||||
@@ -338,7 +411,7 @@ This will direct any requests to `lancache.steamcontent.com` to your SteamCache2
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone <repository-url>
|
||||
cd SteamCache2
|
||||
cd steamcache2
|
||||
|
||||
# Download dependencies
|
||||
make deps
|
||||
@@ -390,6 +463,11 @@ make
|
||||
- Consider using a different GC algorithm like `hybrid`
|
||||
- Adjust the disk cache size to match available storage
|
||||
|
||||
6. **Not sure if it is caching**
|
||||
- Do not start with the full SteamPrefill chapter. Use [Quick check: is it caching?](#quick-check-is-it-caching): `make validate-check` (full `/metrics`, hit/miss fields, and `/lancache-heartbeat`)
|
||||
- A first pass is mostly `cache_misses`; repeating the same content should raise `cache_hits` / `hit_rate`
|
||||
- Confirm the process is up with `curl -s -i http://localhost/lancache-heartbeat` (GET, not HEAD)
|
||||
|
||||
### Getting Help
|
||||
|
||||
- Check the logs for detailed error messages
|
||||
|
||||
+15
-2
@@ -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 {
|
||||
@@ -129,6 +137,9 @@ var rootCmd = &cobra.Command{
|
||||
finalMaxRequestsPerClient,
|
||||
cfg.MaxObjectSize,
|
||||
cfg.TrustedProxies,
|
||||
cfg.Cache.NegativeTTL,
|
||||
cfg.UplinkBandwidth,
|
||||
cfg.MaxBytesPerClientPerSec,
|
||||
)
|
||||
if err != nil {
|
||||
logger.Logger.Error().
|
||||
@@ -169,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)")
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"net"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/docker/go-units"
|
||||
"gopkg.in/yaml.v3"
|
||||
@@ -18,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.
|
||||
@@ -35,6 +41,11 @@ type CacheConfig struct {
|
||||
|
||||
// Disk cache settings
|
||||
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 {
|
||||
@@ -100,6 +111,9 @@ func LoadConfig(configPath string) (*Config, error) {
|
||||
if config.Cache.Disk.GCAlgorithm == "" {
|
||||
config.Cache.Disk.GCAlgorithm = "lru"
|
||||
}
|
||||
if config.Cache.NegativeTTL == "" {
|
||||
config.Cache.NegativeTTL = "5m"
|
||||
}
|
||||
|
||||
return &config, nil
|
||||
}
|
||||
@@ -126,6 +140,7 @@ func SaveDefaultConfig(configPath string) error {
|
||||
Path: "./disk",
|
||||
GCAlgorithm: "lru", // Better for gaming patterns (keeps recently played games)
|
||||
},
|
||||
NegativeTTL: "5m",
|
||||
},
|
||||
Upstream: "",
|
||||
}
|
||||
@@ -162,6 +177,7 @@ func GetDefaultConfig() Config {
|
||||
Path: "./disk",
|
||||
GCAlgorithm: "lru",
|
||||
},
|
||||
NegativeTTL: "5m",
|
||||
},
|
||||
Upstream: "",
|
||||
}
|
||||
@@ -175,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 {
|
||||
@@ -188,6 +212,16 @@ func (c Config) Validate() error {
|
||||
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)
|
||||
if c.MaxObjectSize != "" && c.MaxObjectSize != "0" {
|
||||
if _, err := units.FromHumanSize(c.MaxObjectSize); err != nil {
|
||||
|
||||
@@ -156,6 +156,44 @@ func TestValidate(t *testing.T) {
|
||||
}(),
|
||||
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 {
|
||||
@@ -173,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)
|
||||
}
|
||||
|
||||
@@ -22,12 +22,16 @@
|
||||
#
|
||||
# Usage (typical dev workflow):
|
||||
# make build
|
||||
# ./scripts/validate-with-prefill.sh
|
||||
# make validate
|
||||
# # In another terminal:
|
||||
# SteamPrefill benchmark run -c 20 ...
|
||||
#
|
||||
# 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
|
||||
#
|
||||
# Tweak sizes upward if you want to run very large workloads while still
|
||||
# exercising the disk tier (workload >> RAM is ideal for real disk testing).
|
||||
@@ -35,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)
|
||||
@@ -43,12 +48,15 @@ trusted_proxies: ["127.0.0.0/8"]
|
||||
cache:
|
||||
memory:
|
||||
size: 1GB
|
||||
gc_algorithm: largest
|
||||
gc_algorithm: hybrid
|
||||
disk:
|
||||
size: 2GB
|
||||
path: ./validate-disk # ephemeral; clean between runs if you want a fresh test
|
||||
path: ./validate-disk # cleaned between runs by make validate or make clean-disk
|
||||
gc_algorithm: hybrid # recommended for disk in the project README
|
||||
|
||||
# Empty upstream = use Host header from the client (SteamPrefill / real Steam clients).
|
||||
# This matches the common "DNS points lancache.steamcontent.com at the cache" setup.
|
||||
# Allows for chaining steamcache2 instances if needed.
|
||||
# For example, for a lan party you could have a small fast ram only cache at each table pointing to a larger slower disk cache in the back somewhere
|
||||
# It would reduce the amount of bandwidth needed to the internet and the amount needed to each table
|
||||
# just as a little reminder there is no authentication so this is not a good idea for a public cache just out on the internet.
|
||||
upstream: ""
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
module s1d3sw1ped/steamcache2
|
||||
|
||||
go 1.23.0
|
||||
go 1.27.0
|
||||
|
||||
require (
|
||||
github.com/docker/go-units v0.5.0
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -1,182 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# validate-with-prefill.sh
|
||||
#
|
||||
# Thin glue script to make it trivial for developers to validate complete
|
||||
# steamcache2 functionality using the external SteamPrefill (lancacheprefill)
|
||||
# tool as the realistic client simulator.
|
||||
#
|
||||
# Usage:
|
||||
# 1. make build
|
||||
# 2. ./scripts/validate-with-prefill.sh
|
||||
# (Automatically kills any leftover steamcache2 on the target port first.)
|
||||
# 3. In another terminal (or on another machine), run the printed
|
||||
# SteamPrefill benchmark commands (the script tells you the exact address/port).
|
||||
# 4. After the benchmark finishes, run the suggested metrics check.
|
||||
# 5. Ctrl-C here to cleanly stop the steamcache2 instance.
|
||||
#
|
||||
# This script + the accompanying validate-config.yaml + README docs are the
|
||||
# entire "couple little scripts to hook steamcache2 and lancacheprefill together"
|
||||
# implementation. No Go code, no new dependencies, stays outside go test / bench.
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
# --- Locate the built steamcache2 binary (produced by "make build") ---
|
||||
BINARY=""
|
||||
for candidate in \
|
||||
"dist/default_linux_amd64_v1/steamcache2" \
|
||||
"dist/steamcache2" \
|
||||
"./steamcache2" \
|
||||
"steamcache2"
|
||||
do
|
||||
if [[ -x "$candidate" ]]; then
|
||||
BINARY="$candidate"
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ -z "$BINARY" ]]; then
|
||||
echo "ERROR: Could not find a built steamcache2 binary."
|
||||
echo "Run 'make build' first (or place the binary in one of the searched locations)."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Using steamcache2 binary: $BINARY"
|
||||
|
||||
# --- Validation config (small dual-tier so disk + GC get real exercise) ---
|
||||
# Source of truth lives in docs/examples/ (safe from "make clean").
|
||||
# The script will also accept an explicit path via STEAMCACHE2_VALIDATE_CONFIG.
|
||||
VALIDATE_CONFIG="${STEAMCACHE2_VALIDATE_CONFIG:-docs/examples/validate-config.yaml}"
|
||||
if [[ ! -f "$VALIDATE_CONFIG" ]]; then
|
||||
echo "ERROR: Validation config not found at: $VALIDATE_CONFIG"
|
||||
echo "Set STEAMCACHE2_VALIDATE_CONFIG=/path/to/your-config.yaml or place a copy at docs/examples/validate-config.yaml"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Extract the listen port from the config (supports ":80", "127.0.0.1:80", etc.)
|
||||
# Falls back to 80 if we can't parse it.
|
||||
PORT=$(grep -E '^\s*listen_address:' "$VALIDATE_CONFIG" | head -1 | sed -E 's/.*:([0-9]+).*/\1/' || true)
|
||||
if [[ -z "$PORT" || ! "$PORT" =~ ^[0-9]+$ ]]; then
|
||||
PORT=80
|
||||
fi
|
||||
|
||||
SERVER_URL="http://localhost:${PORT}"
|
||||
|
||||
# For privileged ports (<1024, i.e. the default :80) we require the
|
||||
# cap_net_bind_service capability. We apply it automatically here (via sudo
|
||||
# setcap) on the binary we are about to run. This happens after any build
|
||||
# so the cap is never "lost" when the binary is rebuilt.
|
||||
if [ "$PORT" -lt 1024 ] && [ "$(id -u)" -ne 0 ]; then
|
||||
if ! command -v getcap >/dev/null 2>&1 || ! getcap "$BINARY" 2>/dev/null | grep -q "cap_net_bind_service"; then
|
||||
echo "Setting cap_net_bind_service on the binary (sudo may prompt)..."
|
||||
if ! sudo setcap 'cap_net_bind_service=+ep' "$BINARY"; then
|
||||
echo "ERROR: Failed to set capability."
|
||||
echo "Run 'make setcap' manually, then retry."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# Safely kill only steamcache2 processes listening on this specific port.
|
||||
# We look up PIDs on the port, check their actual process name/command,
|
||||
# and only kill via PID if it looks like steamcache2.
|
||||
echo "Checking for leftover steamcache2 processes on port ${PORT}..."
|
||||
|
||||
kill_steamcache_on_port() {
|
||||
local port=$1
|
||||
local pids=""
|
||||
|
||||
# Try ss first (modern, usually available)
|
||||
if command -v ss >/dev/null 2>&1; then
|
||||
pids=$(ss -tlnp 2>/dev/null | grep ":${port} " | sed -n 's/.*pid=\([0-9]*\).*/\1/p' | sort -u)
|
||||
fi
|
||||
|
||||
# Fallback to lsof
|
||||
if [[ -z "$pids" ]] && command -v lsof >/dev/null 2>&1; then
|
||||
pids=$(lsof -ti :${port} 2>/dev/null | sort -u)
|
||||
fi
|
||||
|
||||
if [[ -z "$pids" ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
for pid in $pids; do
|
||||
# Get process name and command line
|
||||
local proc_name
|
||||
local cmdline
|
||||
proc_name=$(ps -p "$pid" -o comm= 2>/dev/null || true)
|
||||
cmdline=$(ps -p "$pid" -o cmd= 2>/dev/null || true)
|
||||
|
||||
# Check if this looks like a steamcache2 process
|
||||
if echo "$proc_name $cmdline" | grep -qi "steamcache"; then
|
||||
echo " → Found steamcache2 on port ${port} (PID $pid, name: ${proc_name:-unknown})"
|
||||
kill -TERM "$pid" 2>/dev/null || true
|
||||
sleep 0.3
|
||||
# If still alive, force kill
|
||||
if kill -0 "$pid" 2>/dev/null; then
|
||||
kill -9 "$pid" 2>/dev/null || true
|
||||
fi
|
||||
echo " Killed PID $pid"
|
||||
else
|
||||
echo " → Skipping PID $pid on port ${port} (not steamcache2: ${proc_name:-$cmdline})"
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
kill_steamcache_on_port "$PORT"
|
||||
sleep 0.5
|
||||
|
||||
# --- Launch the server in the background ---
|
||||
echo "Starting steamcache2 with validation config (small caches for disk/GC testing)..."
|
||||
"$BINARY" --config "$VALIDATE_CONFIG" --log-level info &
|
||||
SERVER_PID=$!
|
||||
|
||||
# Ensure we always clean up the child on exit / Ctrl-C / error
|
||||
cleanup() {
|
||||
echo ""
|
||||
echo "Stopping steamcache2 (pid $SERVER_PID)..."
|
||||
if kill "$SERVER_PID" 2>/dev/null; then
|
||||
wait "$SERVER_PID" 2>/dev/null || true
|
||||
fi
|
||||
echo "Server stopped."
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
# Give the server a moment to bind and pass its own startup checks
|
||||
sleep 2
|
||||
|
||||
# Basic readiness probe using the actual configured port
|
||||
if ! curl -s --max-time 3 "${SERVER_URL}/" >/dev/null 2>&1; then
|
||||
echo "WARNING: Server did not respond quickly on ${SERVER_URL}/"
|
||||
echo " It may still be starting or bound to a different address."
|
||||
echo " Check the server logs above. You can still try the SteamPrefill commands."
|
||||
fi
|
||||
|
||||
if [[ "${VALIDATE_QUIET:-}" != "1" ]]; then
|
||||
echo ""
|
||||
echo "======================================================================"
|
||||
echo "steamcache2 is running (validation mode) on ${SERVER_URL}"
|
||||
echo ""
|
||||
echo "In another terminal (or on a machine that can reach this one), run:"
|
||||
echo ""
|
||||
echo " # One-time workload creation (run on a machine with SteamPrefill + Steam):"
|
||||
echo " SteamPrefill benchmark setup --preset LargeChunks"
|
||||
echo " # (or --use-selected, --all, --appid ..., or your own preset)"
|
||||
echo ""
|
||||
echo " # Copy the generated workload file to this machine if needed."
|
||||
echo ""
|
||||
echo " # Then run the actual benchmark (this is the realistic client simulator):"
|
||||
echo " SteamPrefill benchmark run -c 20 -i 3"
|
||||
echo ""
|
||||
echo "After the benchmark completes, you can inspect the cache with:"
|
||||
echo " curl -s ${SERVER_URL}/metrics | cat"
|
||||
echo ""
|
||||
echo "Or run: make validate-check (if the Makefile target exists)"
|
||||
echo ""
|
||||
echo "When you are finished, press Ctrl-C in this window to stop the server cleanly."
|
||||
echo "======================================================================"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# Wait for the background server (or for the user to Ctrl-C)
|
||||
wait $SERVER_PID || true
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+45
-12
@@ -20,10 +20,12 @@ import (
|
||||
//
|
||||
// On-disk format (documented here at top of format.go per Phase 2 plan; stable v1):
|
||||
// File = header-line + raw-response-bytes
|
||||
// header-line = "SC2C " + 64hex(bodySHA256) + " " + strconv(len(rawResp)) + "\n"
|
||||
// raw-response-bytes = the exact bytes from reconstructRawResponse (HTTP/1.1 status\r\n + headers\r\n\r\n + body)
|
||||
// deserializeCacheFile: parses header, verifies size+SHA, returns CacheFileFormat.
|
||||
// No compression or extra fields. filterHopByHopHeaders is the shared helper
|
||||
// header-line = "SC2C " + 64hex(bodySHA256) + " " + strconv(len(rawResp)) [+ " " + expires-unix] + "\n"
|
||||
// Positive objects keep 3 fields. Negative (404/410) entries add an optional 4th
|
||||
// expires-unix field (seconds since epoch); deserialize treats 3-field files as
|
||||
// non-expiring. raw-response-bytes = reconstructRawResponse (HTTP/1.1 status\r\n
|
||||
// + 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).
|
||||
const (
|
||||
CacheFileMagic = "SC2C" // SteamCache2 Cache
|
||||
@@ -34,11 +36,19 @@ type CacheFileFormat struct {
|
||||
ContentHash string // SHA256 hash of the response body (internal)
|
||||
ResponseSize int64 // Size of the entire HTTP response
|
||||
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
|
||||
// upstreamHash and upstreamAlgo are used for verification during download but not stored
|
||||
// (positive object: 3-field SC2C header, no expiry).
|
||||
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
|
||||
bodyStart := bytes.Index(rawResponse, []byte("\r\n\r\n"))
|
||||
if bodyStart == -1 {
|
||||
@@ -53,8 +63,13 @@ func serializeRawResponse(rawResponse []byte) ([]byte, error) {
|
||||
// Create text-based cache file
|
||||
var buf bytes.Buffer
|
||||
|
||||
// First line: magic number, content hash, response size
|
||||
headerLine := fmt.Sprintf("%s %s %d\n", CacheFileMagic, contentHash, len(rawResponse))
|
||||
// First line: magic number, content hash, response size [, expires-unix]
|
||||
var headerLine string
|
||||
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)
|
||||
|
||||
// Rest of the file: raw HTTP response
|
||||
@@ -75,11 +90,11 @@ func deserializeCacheFile(data []byte) (*CacheFileFormat, error) {
|
||||
return nil, fmt.Errorf("invalid cache file format: no header line found")
|
||||
}
|
||||
|
||||
// Parse header line: "SC2C <hash> <size>"
|
||||
// Parse header line: "SC2C <hash> <size>" or "SC2C <hash> <size> <expires-unix>"
|
||||
headerLine := string(data[:newlineIndex])
|
||||
parts := strings.Fields(headerLine)
|
||||
if len(parts) != 3 {
|
||||
return nil, fmt.Errorf("invalid header format: expected 3 fields, got %d", len(parts))
|
||||
if len(parts) != 3 && len(parts) != 4 {
|
||||
return nil, fmt.Errorf("invalid header format: expected 3 or 4 fields, got %d", len(parts))
|
||||
}
|
||||
|
||||
// Check magic number
|
||||
@@ -99,6 +114,14 @@ func deserializeCacheFile(data []byte) (*CacheFileFormat, error) {
|
||||
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)
|
||||
rawResponse := data[newlineIndex+1:]
|
||||
|
||||
@@ -128,6 +151,7 @@ func deserializeCacheFile(data []byte) (*CacheFileFormat, error) {
|
||||
ContentHash: contentHash,
|
||||
ResponseSize: responseSize,
|
||||
Response: rawResponse,
|
||||
ExpiresUnix: expiresUnix,
|
||||
}
|
||||
|
||||
return cacheFile, nil
|
||||
@@ -222,9 +246,11 @@ func (sc *SteamCache) streamCachedResponse(w http.ResponseWriter, r *http.Reques
|
||||
bodyStart := responseReader.Size() - int64(responseReader.Len())
|
||||
bodyData := cacheFile.Response[bodyStart:]
|
||||
|
||||
// Handle Range requests
|
||||
// Handle Range requests on cached 200 bodies only. Cached 404/410 (negative
|
||||
// entries) are served as the stored status; slicing an error body as 206
|
||||
// would be wrong.
|
||||
rangeHeader := r.Header.Get("Range")
|
||||
if rangeHeader != "" {
|
||||
if rangeHeader != "" && statusCode == http.StatusOK {
|
||||
// Parse the range request
|
||||
start, end, totalSize, valid := parseRangeHeader(rangeHeader, int64(len(bodyData)))
|
||||
if !valid {
|
||||
@@ -262,6 +288,13 @@ func (sc *SteamCache) streamCachedResponse(w http.ResponseWriter, r *http.Reques
|
||||
// Send the range data
|
||||
_, _ = w.Write(rangeData) // client write error ignored (disconnect during range body send is not actionable)
|
||||
|
||||
// Range served from cache: count the range-specific metric and the range
|
||||
// bytes actually written (handleCacheHit skips full-blob byte counting for
|
||||
// Range requests so these are not double-counted).
|
||||
sc.metrics.IncrementRangeCache()
|
||||
sc.metrics.AddBytesServed(int64(len(rangeData)))
|
||||
sc.metrics.AddBytesSaved(int64(len(rangeData)))
|
||||
|
||||
logger.Logger.Info().
|
||||
Str("cache_key", cacheKey).
|
||||
Str("url", r.URL.String()).
|
||||
|
||||
+224
-50
@@ -34,10 +34,18 @@ type Options struct {
|
||||
// New config fields for hardening (max object size + trusted proxies)
|
||||
MaxObjectSize 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) {
|
||||
return New(o.Address, o.MemorySize, o.DiskSize, o.DiskPath, o.Upstream, o.MemoryGC, o.DiskGC, o.MaxConcurrentRequests, o.MaxRequestsPerClient, o.MaxObjectSize, o.TrustedProxies)
|
||||
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
|
||||
@@ -56,6 +64,15 @@ func (sc *SteamCache) handleSpecialEndpoints(w http.ResponseWriter, r *http.Requ
|
||||
logger.Logger.Debug().
|
||||
Str("client_ip", clientIP).
|
||||
Msg("LanCache heartbeat request")
|
||||
diskTier := "disabled"
|
||||
if sc.disk != nil {
|
||||
if sc.metrics.GetDiskTierReady() == 1 {
|
||||
diskTier = "ready"
|
||||
} else {
|
||||
diskTier = "pending"
|
||||
}
|
||||
}
|
||||
w.Header().Add("X-SteamCache-Disk-Tier", diskTier)
|
||||
w.Header().Add("X-LanCache-Processed-By", "SteamCache2")
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
_, _ = w.Write(nil) // client write error ignored (heartbeat path; nil write is no-op)
|
||||
@@ -63,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
|
||||
@@ -106,11 +123,29 @@ func (sc *SteamCache) handleCacheHit(w http.ResponseWriter, r *http.Request, cac
|
||||
Msg("Failed to deserialize cache file - removing corrupted entry")
|
||||
_ = sc.vfs.Delete(cachePath) // best-effort cleanup of corrupt entry; failure non-fatal (logged)
|
||||
} 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
|
||||
sc.metrics.IncrementCacheHits()
|
||||
sc.metrics.AddResponseTime(time.Since(tstart))
|
||||
sc.metrics.AddBytesServed(int64(len(cachedData)))
|
||||
sc.metrics.AddBytesSaved(int64(len(cachedData)))
|
||||
if r.Header.Get("Range") == "" {
|
||||
// Full-object HIT: count the cached blob served. Range HITs skip
|
||||
// this here — streamCachedResponse counts the range bytes actually
|
||||
// written to the client instead, so BytesServed/Saved reflect the
|
||||
// partial body and are not double-counted.
|
||||
sc.metrics.AddBytesServed(int64(len(cachedData)))
|
||||
sc.metrics.AddBytesSaved(int64(len(cachedData)))
|
||||
}
|
||||
sc.metrics.IncrementServiceRequests(service.Name)
|
||||
|
||||
logger.Logger.Debug().
|
||||
@@ -129,6 +164,126 @@ func (sc *SteamCache) handleCacheHit(w http.ResponseWriter, r *http.Request, cac
|
||||
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.
|
||||
// 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).
|
||||
@@ -262,11 +417,23 @@ 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 {
|
||||
// trim the query parameters from the URL path
|
||||
// this is necessary because the cache key should not include query parameters
|
||||
urlPath := strings.SplitN(r.URL.String(), "?", 2)[0] // trim query for cache key (SplitN makes intent explicit vs Cut + ignored bool)
|
||||
// Cache key is the path only, never the Host: Steam rotates CDN hostnames
|
||||
// for the same depot object, so different Host headers (or absolute-form
|
||||
// request targets) for the same path must share one cache entry. r.URL.Path
|
||||
// is the decoded path (query is never part of it); validateURLPath checks
|
||||
// this decoded form and url.JoinPath re-escapes it for the upstream join.
|
||||
urlPath := r.URL.Path
|
||||
|
||||
// Validate URL path for security
|
||||
if err := validateURLPath(urlPath); err != nil {
|
||||
@@ -345,6 +512,18 @@ func (sc *SteamCache) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
req.Host = r.Host
|
||||
} else { // if no upstream server is configured, proxy the request to the host specified in the request
|
||||
host := r.Host
|
||||
if !hostAllowedForDirectFetch(host) {
|
||||
logger.Logger.Warn().
|
||||
Str("host", host).
|
||||
Str("client_ip", clientIP).
|
||||
Msg("Rejecting direct-fetch Host (not a Steam CDN name)")
|
||||
sc.metrics.IncrementErrors()
|
||||
if isNew {
|
||||
coalescedReq.complete(nil, fmt.Errorf("host not allowed for direct fetch"))
|
||||
}
|
||||
http.Error(w, "Invalid URL", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if r.Header.Get("X-Sls-Https") == "enable" {
|
||||
host = "https://" + host
|
||||
} else {
|
||||
@@ -387,12 +566,12 @@ func (sc *SteamCache) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// Retry logic
|
||||
// Retry logic. 404/410 are definitive gone: do not retry with backoff.
|
||||
backoffSchedule := []time.Duration{1 * time.Second, 3 * time.Second, 10 * time.Second}
|
||||
var resp *http.Response
|
||||
for i, backoff := range backoffSchedule {
|
||||
resp, err = sc.client.Do(req)
|
||||
if err == nil && resp.StatusCode == http.StatusOK {
|
||||
if err == nil && (resp.StatusCode == http.StatusOK || isDefinitiveGone(resp.StatusCode)) {
|
||||
break
|
||||
}
|
||||
if i < len(backoffSchedule)-1 {
|
||||
@@ -417,6 +596,10 @@ func (sc *SteamCache) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
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)")
|
||||
|
||||
_ = resp.Body.Close() // best-effort close on non-OK upstream; primary error path
|
||||
@@ -537,14 +720,40 @@ func (sc *SteamCache) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("X-LanCache-Status", "MISS")
|
||||
w.Header().Set("X-LanCache-Processed-By", "SteamCache2")
|
||||
|
||||
// Stream the response body to client
|
||||
w.WriteHeader(resp.StatusCode)
|
||||
_, _ = w.Write(bodyData) // client write error ignored (disconnect during MISS body send is not actionable)
|
||||
// Stream the response body to client.
|
||||
// Range miss: the Range header was stripped for the upstream fetch (so the
|
||||
// FULL object is cached below); serve the client's requested slice from the
|
||||
// full body as 206, matching the HIT Range path.
|
||||
if rangeHeader := r.Header.Get("Range"); rangeHeader != "" {
|
||||
start, end, totalSize, rangeValid := parseRangeHeader(rangeHeader, int64(len(bodyData)))
|
||||
if !rangeValid {
|
||||
// Invalid range — 416 (consistent with the HIT Range path). Drop the
|
||||
// upstream Content-Length: it describes the full body, which 416 does
|
||||
// not send (a stale CL would hang clients waiting for a body).
|
||||
w.Header().Del("Content-Length")
|
||||
w.Header().Set("Content-Range", fmt.Sprintf("bytes */%d", len(bodyData)))
|
||||
w.WriteHeader(http.StatusRequestedRangeNotSatisfiable)
|
||||
} else {
|
||||
rangeData := bodyData[start : end+1]
|
||||
w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, end, totalSize))
|
||||
w.Header().Set("Content-Length", fmt.Sprintf("%d", len(rangeData)))
|
||||
w.Header().Set("Accept-Ranges", "bytes")
|
||||
w.WriteHeader(http.StatusPartialContent)
|
||||
_, _ = w.Write(rangeData) // client write error ignored (disconnect during MISS range body send is not actionable)
|
||||
|
||||
// Range required an upstream fetch (full object) then served as 206
|
||||
sc.metrics.IncrementRangeUpstream()
|
||||
sc.metrics.AddBytesServed(int64(len(rangeData))) // range bytes only, not the full cached object
|
||||
}
|
||||
} else {
|
||||
w.WriteHeader(resp.StatusCode)
|
||||
_, _ = w.Write(bodyData) // client write error ignored (disconnect during MISS body send is not actionable)
|
||||
sc.metrics.AddBytesServed(int64(len(bodyData)))
|
||||
}
|
||||
|
||||
// Track cache miss metrics
|
||||
sc.metrics.IncrementCacheMisses()
|
||||
sc.metrics.AddResponseTime(time.Since(tstart))
|
||||
sc.metrics.AddBytesServed(int64(len(bodyData)))
|
||||
sc.metrics.IncrementServiceRequests(service.Name)
|
||||
|
||||
// Verify we received the complete file by checking Content-Length
|
||||
@@ -572,42 +781,7 @@ func (sc *SteamCache) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
sc.metrics.IncrementCacheWriteFailures()
|
||||
sc.metrics.IncrementServiceError("serialize")
|
||||
} else {
|
||||
// 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")
|
||||
}
|
||||
sc.writeCacheEntry(cachePath, cacheKey, urlPath, service.Name, cacheData)
|
||||
}
|
||||
|
||||
// Complete coalesced request with the original response
|
||||
|
||||
+218
-80
@@ -7,32 +7,39 @@ import (
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"s1d3sw1ped/steamcache2/steamcache/logger"
|
||||
)
|
||||
|
||||
// Metrics tracks various performance and operational metrics
|
||||
type Metrics struct {
|
||||
// Request metrics
|
||||
TotalRequests int64
|
||||
CacheHits int64
|
||||
CacheMisses int64
|
||||
CacheCoalesced int64
|
||||
Errors int64
|
||||
RateLimited int64
|
||||
TotalRequests int64
|
||||
CacheHits int64
|
||||
CacheMisses 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)
|
||||
RangeUpstream int64 // Range requests that required an upstream fetch (full object), served as 206
|
||||
Errors int64
|
||||
RateLimited int64
|
||||
|
||||
// Performance metrics
|
||||
TotalResponseTime int64 // in nanoseconds
|
||||
TotalBytesServed int64
|
||||
TotalBytesSaved int64 // bytes served from cache instead of being re-downloaded from upstream
|
||||
|
||||
|
||||
|
||||
// Cache metrics
|
||||
MemoryCacheSize int64
|
||||
DiskCacheSize int64
|
||||
MemoryCacheHits int64
|
||||
DiskCacheHits int64
|
||||
Promotions int64
|
||||
Evictions int64
|
||||
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
|
||||
Evictions int64
|
||||
CapacityPressureEvents int64 // soft eviction under cap and/or disk ENOSPC
|
||||
DiskTierReady int64 // 0=pending (or unset), 1=ready or no-disk (N/A)
|
||||
|
||||
// Expanded observability (upstream breakdowns, cache write failures, per-service errors)
|
||||
UpstreamErrors int64
|
||||
@@ -75,11 +82,27 @@ func (m *Metrics) IncrementCacheMisses() {
|
||||
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
|
||||
func (m *Metrics) IncrementCacheCoalesced() {
|
||||
atomic.AddInt64(&m.CacheCoalesced, 1)
|
||||
}
|
||||
|
||||
// IncrementRangeCache increments the Range-from-cache counter (HIT served as 206)
|
||||
func (m *Metrics) IncrementRangeCache() {
|
||||
atomic.AddInt64(&m.RangeCache, 1)
|
||||
}
|
||||
|
||||
// IncrementRangeUpstream increments the Range-from-upstream counter (full object
|
||||
// fetched upstream, requested slice served as 206)
|
||||
func (m *Metrics) IncrementRangeUpstream() {
|
||||
atomic.AddInt64(&m.RangeUpstream, 1)
|
||||
}
|
||||
|
||||
// IncrementErrors increments the error counter
|
||||
func (m *Metrics) IncrementErrors() {
|
||||
atomic.AddInt64(&m.Errors, 1)
|
||||
@@ -116,6 +139,28 @@ 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) {
|
||||
atomic.StoreInt64(&m.DiskTierReady, ready)
|
||||
}
|
||||
|
||||
// GetDiskTierReady returns 1 if disk tier is ready (or no disk configured), else 0 while attach pending.
|
||||
func (m *Metrics) GetDiskTierReady() int64 {
|
||||
return atomic.LoadInt64(&m.DiskTierReady)
|
||||
}
|
||||
|
||||
// IncrementMemoryCacheHits increments memory cache hits
|
||||
func (m *Metrics) IncrementMemoryCacheHits() {
|
||||
atomic.AddInt64(&m.MemoryCacheHits, 1)
|
||||
@@ -140,8 +185,39 @@ func (m *Metrics) GetServiceRequests(service string) int64 {
|
||||
return m.ServiceRequests[service]
|
||||
}
|
||||
|
||||
func (m *Metrics) IncrementPromotions() { atomic.AddInt64(&m.Promotions, 1) }
|
||||
func (m *Metrics) IncrementEvictions() { atomic.AddInt64(&m.Evictions, 1) }
|
||||
func (m *Metrics) IncrementPromotions() { atomic.AddInt64(&m.Promotions, 1) }
|
||||
func (m *Metrics) IncrementEvictions() { atomic.AddInt64(&m.Evictions, 1) }
|
||||
func (m *Metrics) IncrementCapacityPressureEvents() { atomic.AddInt64(&m.CapacityPressureEvents, 1) }
|
||||
|
||||
// NoteSoftEviction records one cap-pressure eviction batch that freed bytes.
|
||||
// Keeps the existing evictions counter and also increments capacity_pressure_events.
|
||||
// Nil m is safe: the log still fires so ops can grep without metrics wired.
|
||||
func NoteSoftEviction(m *Metrics, tier string, evicted uint) {
|
||||
if evicted == 0 {
|
||||
return
|
||||
}
|
||||
if m != nil {
|
||||
m.IncrementEvictions()
|
||||
m.IncrementCapacityPressureEvents()
|
||||
}
|
||||
logger.Logger.Info().
|
||||
Str("tier", tier).
|
||||
Str("reason", "eviction").
|
||||
Uint("bytes_evicted", evicted).
|
||||
Msg("cache capacity pressure")
|
||||
}
|
||||
|
||||
// NoteNoSpace records a disk Create/Write/Mkdir ENOSPC (or equivalent) event.
|
||||
func NoteNoSpace(m *Metrics, err error) {
|
||||
if m != nil {
|
||||
m.IncrementCapacityPressureEvents()
|
||||
}
|
||||
logger.Logger.Warn().
|
||||
Str("tier", "disk").
|
||||
Str("reason", "enospc").
|
||||
Err(err).
|
||||
Msg("cache capacity pressure")
|
||||
}
|
||||
|
||||
// Additional observability counters
|
||||
func (m *Metrics) IncrementUpstreamErrors() { atomic.AddInt64(&m.UpstreamErrors, 1) }
|
||||
@@ -185,38 +261,67 @@ 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,
|
||||
CacheMisses: cacheMisses,
|
||||
CacheCoalesced: atomic.LoadInt64(&m.CacheCoalesced),
|
||||
Errors: atomic.LoadInt64(&m.Errors),
|
||||
RateLimited: atomic.LoadInt64(&m.RateLimited),
|
||||
HitRate: hitRate,
|
||||
AvgResponseTime: avgResponseTime,
|
||||
TotalBytesServed: atomic.LoadInt64(&m.TotalBytesServed),
|
||||
TotalBytesSaved: atomic.LoadInt64(&m.TotalBytesSaved),
|
||||
MemoryCacheSize: atomic.LoadInt64(&m.MemoryCacheSize),
|
||||
DiskCacheSize: atomic.LoadInt64(&m.DiskCacheSize),
|
||||
MemoryCacheHits: atomic.LoadInt64(&m.MemoryCacheHits),
|
||||
DiskCacheHits: atomic.LoadInt64(&m.DiskCacheHits),
|
||||
Promotions: atomic.LoadInt64(&m.Promotions),
|
||||
Evictions: atomic.LoadInt64(&m.Evictions),
|
||||
ServiceRequests: serviceRequests,
|
||||
UpstreamErrors: atomic.LoadInt64(&m.UpstreamErrors),
|
||||
CacheWriteFailures: atomic.LoadInt64(&m.CacheWriteFailures),
|
||||
ServiceErrors: serviceErrors,
|
||||
Uptime: time.Since(m.StartTime),
|
||||
LastResetTime: m.LastResetTime,
|
||||
TotalRequests: totalRequests,
|
||||
CacheHits: cacheHits,
|
||||
CacheMisses: cacheMisses,
|
||||
CacheCoalesced: atomic.LoadInt64(&m.CacheCoalesced),
|
||||
NegativeCacheHits: atomic.LoadInt64(&m.NegativeCacheHits),
|
||||
RangeCache: atomic.LoadInt64(&m.RangeCache),
|
||||
RangeUpstream: atomic.LoadInt64(&m.RangeUpstream),
|
||||
Errors: atomic.LoadInt64(&m.Errors),
|
||||
RateLimited: atomic.LoadInt64(&m.RateLimited),
|
||||
HitRate: hitRate,
|
||||
AvgResponseTime: avgResponseTime,
|
||||
TotalBytesServed: atomic.LoadInt64(&m.TotalBytesServed),
|
||||
TotalBytesSaved: atomic.LoadInt64(&m.TotalBytesSaved),
|
||||
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),
|
||||
Promotions: atomic.LoadInt64(&m.Promotions),
|
||||
Evictions: atomic.LoadInt64(&m.Evictions),
|
||||
CapacityPressureEvents: atomic.LoadInt64(&m.CapacityPressureEvents),
|
||||
ServiceRequests: serviceRequests,
|
||||
UpstreamErrors: atomic.LoadInt64(&m.UpstreamErrors),
|
||||
CacheWriteFailures: atomic.LoadInt64(&m.CacheWriteFailures),
|
||||
ServiceErrors: serviceErrors,
|
||||
Uptime: time.Since(m.StartTime),
|
||||
LastResetTime: m.LastResetTime,
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
atomic.StoreInt64(&m.CacheHits, 0)
|
||||
atomic.StoreInt64(&m.CacheMisses, 0)
|
||||
atomic.StoreInt64(&m.CacheCoalesced, 0)
|
||||
atomic.StoreInt64(&m.NegativeCacheHits, 0)
|
||||
atomic.StoreInt64(&m.RangeCache, 0)
|
||||
atomic.StoreInt64(&m.RangeUpstream, 0)
|
||||
atomic.StoreInt64(&m.Errors, 0)
|
||||
atomic.StoreInt64(&m.RateLimited, 0)
|
||||
atomic.StoreInt64(&m.TotalResponseTime, 0)
|
||||
@@ -226,6 +331,7 @@ func (m *Metrics) Reset() {
|
||||
atomic.StoreInt64(&m.DiskCacheHits, 0)
|
||||
atomic.StoreInt64(&m.Promotions, 0)
|
||||
atomic.StoreInt64(&m.Evictions, 0)
|
||||
atomic.StoreInt64(&m.CapacityPressureEvents, 0)
|
||||
atomic.StoreInt64(&m.UpstreamErrors, 0)
|
||||
atomic.StoreInt64(&m.CacheWriteFailures, 0)
|
||||
|
||||
@@ -242,63 +348,95 @@ func (m *Metrics) Reset() {
|
||||
|
||||
// Stats represents a snapshot of metrics
|
||||
type Stats struct {
|
||||
TotalRequests int64
|
||||
CacheHits int64
|
||||
CacheMisses int64
|
||||
CacheCoalesced int64
|
||||
Errors int64
|
||||
RateLimited int64
|
||||
HitRate float64
|
||||
AvgResponseTime time.Duration
|
||||
TotalRequests int64
|
||||
CacheHits int64
|
||||
CacheMisses int64
|
||||
CacheCoalesced int64
|
||||
NegativeCacheHits int64
|
||||
RangeCache int64
|
||||
RangeUpstream int64
|
||||
Errors int64
|
||||
RateLimited int64
|
||||
HitRate float64
|
||||
AvgResponseTime time.Duration
|
||||
TotalBytesServed int64
|
||||
TotalBytesSaved int64
|
||||
MemoryCacheSize int64
|
||||
|
||||
|
||||
DiskCacheSize int64
|
||||
MemoryCacheHits int64
|
||||
DiskCacheHits int64
|
||||
Promotions int64
|
||||
Evictions int64
|
||||
UpstreamErrors int64
|
||||
CacheWriteFailures int64
|
||||
ServiceErrors map[string]int64
|
||||
ServiceRequests map[string]int64
|
||||
Uptime time.Duration
|
||||
LastResetTime time.Time
|
||||
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
|
||||
Promotions int64
|
||||
Evictions int64
|
||||
CapacityPressureEvents int64
|
||||
UpstreamErrors int64
|
||||
CacheWriteFailures int64
|
||||
ServiceErrors map[string]int64
|
||||
ServiceRequests map[string]int64
|
||||
Uptime time.Duration
|
||||
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, "cache_coalesced %d\n", stats.CacheCoalesced)
|
||||
_, _ = 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)
|
||||
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, "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)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
package metrics
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestCapacityPressureEventsWriteTextAndReset(t *testing.T) {
|
||||
t.Parallel()
|
||||
m := NewMetrics()
|
||||
if got := m.GetStats().CapacityPressureEvents; got != 0 {
|
||||
t.Fatalf("initial CapacityPressureEvents=%d, want 0", got)
|
||||
}
|
||||
|
||||
NoteSoftEviction(m, "memory", 0)
|
||||
if got := m.GetStats().CapacityPressureEvents; got != 0 {
|
||||
t.Fatalf("zero-byte eviction counted: %d", got)
|
||||
}
|
||||
|
||||
NoteSoftEviction(m, "memory", 128)
|
||||
st := m.GetStats()
|
||||
if st.CapacityPressureEvents != 1 {
|
||||
t.Fatalf("after memory eviction, CapacityPressureEvents=%d, want 1", st.CapacityPressureEvents)
|
||||
}
|
||||
if st.Evictions != 1 {
|
||||
t.Fatalf("after memory eviction, Evictions=%d, want 1 (existing counter kept)", st.Evictions)
|
||||
}
|
||||
|
||||
NoteSoftEviction(m, "disk", 64)
|
||||
NoteNoSpace(m, errors.New("no space left on device"))
|
||||
st = m.GetStats()
|
||||
if st.CapacityPressureEvents != 3 {
|
||||
t.Fatalf("after disk eviction + ENOSPC, CapacityPressureEvents=%d, want 3", st.CapacityPressureEvents)
|
||||
}
|
||||
if st.Evictions != 2 {
|
||||
t.Fatalf("ENOSPC must not increment evictions; Evictions=%d, want 2", st.Evictions)
|
||||
}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
WriteText(rec, st)
|
||||
body := rec.Body.Bytes()
|
||||
if !bytes.Contains(body, []byte("capacity_pressure_events 3")) {
|
||||
t.Errorf("WriteText missing capacity_pressure_events 3: %q", rec.Body.String())
|
||||
}
|
||||
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()
|
||||
if st.CapacityPressureEvents != 0 || st.Evictions != 0 {
|
||||
t.Errorf("after Reset, CapacityPressureEvents=%d Evictions=%d, want 0", st.CapacityPressureEvents, st.Evictions)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoteSoftEvictionNilMetrics(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Must not panic when metrics are not wired (unit tests / early init).
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
// steamcache/range_test.go
|
||||
package steamcache
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestRangeHitServedLocally verifies that a Range GET against an already-cached
|
||||
// object is served locally as 206 (no upstream re-fetch) and increments range_cache.
|
||||
func TestRangeHitServedLocally(t *testing.T) {
|
||||
body := []byte("0123456789abcdef") // 16 bytes
|
||||
var upstreamCalls atomic.Int64
|
||||
f := func(w http.ResponseWriter, r *http.Request) {
|
||||
upstreamCalls.Add(1)
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
_, _ = w.Write(body)
|
||||
}
|
||||
sc, _ := newTestCacheWithFakeUpstream(t, f, "1MB", "0")
|
||||
srv := newCacheServer(t, sc)
|
||||
c := &http.Client{Timeout: 5 * time.Second}
|
||||
|
||||
// 1) Populate the cache with a full (non-Range) MISS.
|
||||
req, err := http.NewRequest("GET", srv.URL+"/depot/rangetest/chunk", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("NewRequest: %v", err)
|
||||
}
|
||||
req.Header.Set("User-Agent", "Valve/Steam HTTP Client 1.0")
|
||||
resp, err := c.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("miss GET: %v", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("miss GET: expected 200, got %d", resp.StatusCode)
|
||||
}
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
_ = resp.Body.Close()
|
||||
if got := sc.GetMetrics().CacheMisses; got < 1 {
|
||||
t.Fatalf("expected CacheMisses >= 1 after first GET, got %d", got)
|
||||
}
|
||||
|
||||
// 2) Range GET against the same URL — must be a local 206 HIT.
|
||||
req2, err := http.NewRequest("GET", srv.URL+"/depot/rangetest/chunk", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("NewRequest: %v", err)
|
||||
}
|
||||
req2.Header.Set("User-Agent", "Valve/Steam HTTP Client 1.0")
|
||||
req2.Header.Set("Range", "bytes=4-7")
|
||||
resp2, err := c.Do(req2)
|
||||
if err != nil {
|
||||
t.Fatalf("range GET: %v", err)
|
||||
}
|
||||
data, err := io.ReadAll(resp2.Body)
|
||||
_ = resp2.Body.Close()
|
||||
if err != nil {
|
||||
t.Fatalf("read range body: %v", err)
|
||||
}
|
||||
if resp2.StatusCode != http.StatusPartialContent {
|
||||
t.Fatalf("range GET: expected 206, got %d", resp2.StatusCode)
|
||||
}
|
||||
if string(data) != "4567" {
|
||||
t.Errorf("range GET: expected body %q, got %q", "4567", data)
|
||||
}
|
||||
if got := resp2.Header.Get("X-LanCache-Status"); got != "HIT" {
|
||||
t.Errorf("range GET: expected X-LanCache-Status HIT, got %q", got)
|
||||
}
|
||||
if got := resp2.Header.Get("Content-Range"); got != "bytes 4-7/16" {
|
||||
t.Errorf("range GET: expected Content-Range bytes 4-7/16, got %q", got)
|
||||
}
|
||||
if got := resp2.Header.Get("Accept-Ranges"); got != "bytes" {
|
||||
t.Errorf("range GET: expected Accept-Ranges bytes, got %q", got)
|
||||
}
|
||||
|
||||
// Upstream must NOT have been hit again.
|
||||
if got := upstreamCalls.Load(); got != 1 {
|
||||
t.Errorf("upstream hit %d times, want exactly 1 (range HIT must be local)", got)
|
||||
}
|
||||
|
||||
// range_cache incremented, range_upstream untouched.
|
||||
stats := sc.GetMetrics()
|
||||
if stats.RangeCache != 1 {
|
||||
t.Errorf("expected RangeCache == 1 after range HIT, got %d", stats.RangeCache)
|
||||
}
|
||||
if stats.RangeUpstream != 0 {
|
||||
t.Errorf("expected RangeUpstream == 0 (no range miss yet), got %d", stats.RangeUpstream)
|
||||
}
|
||||
if stats.CacheHits < 1 {
|
||||
t.Errorf("expected CacheHits >= 1 after range HIT, got %d", stats.CacheHits)
|
||||
}
|
||||
// BytesServed: 16 (full miss body) + 4 (range bytes) = 20.
|
||||
if stats.TotalBytesServed != 20 {
|
||||
t.Errorf("expected TotalBytesServed == 20 (16 + range 4), got %d", stats.TotalBytesServed)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRangeMissServes206FromFullFetch verifies that a Range GET on a cold key fetches
|
||||
// the FULL object from upstream (Range stripped), caches it, and serves the requested
|
||||
// slice as 206 with range_upstream incremented.
|
||||
func TestRangeMissServes206FromFullFetch(t *testing.T) {
|
||||
body := []byte("0123456789abcdef") // 16 bytes
|
||||
var upstreamCalls atomic.Int64
|
||||
var upstreamSawRange atomic.Bool
|
||||
f := func(w http.ResponseWriter, r *http.Request) {
|
||||
upstreamCalls.Add(1)
|
||||
if r.Header.Get("Range") != "" {
|
||||
upstreamSawRange.Store(true)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
_, _ = w.Write(body)
|
||||
}
|
||||
sc, _ := newTestCacheWithFakeUpstream(t, f, "1MB", "0")
|
||||
srv := newCacheServer(t, sc)
|
||||
c := &http.Client{Timeout: 5 * time.Second}
|
||||
|
||||
// Range GET on a cold key.
|
||||
req, err := http.NewRequest("GET", srv.URL+"/depot/rangetest/chunk2", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("NewRequest: %v", err)
|
||||
}
|
||||
req.Header.Set("User-Agent", "Valve/Steam HTTP Client 1.0")
|
||||
req.Header.Set("Range", "bytes=0-3")
|
||||
resp, err := c.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("range miss GET: %v", err)
|
||||
}
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
_ = resp.Body.Close()
|
||||
if err != nil {
|
||||
t.Fatalf("read body: %v", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusPartialContent {
|
||||
t.Fatalf("range miss GET: expected 206, got %d", resp.StatusCode)
|
||||
}
|
||||
if string(data) != "0123" {
|
||||
t.Errorf("range miss GET: expected body %q, got %q", "0123", data)
|
||||
}
|
||||
if got := resp.Header.Get("X-LanCache-Status"); got != "MISS" {
|
||||
t.Errorf("range miss GET: expected X-LanCache-Status MISS, got %q", got)
|
||||
}
|
||||
if got := resp.Header.Get("Content-Range"); got != "bytes 0-3/16" {
|
||||
t.Errorf("range miss GET: expected Content-Range bytes 0-3/16, got %q", got)
|
||||
}
|
||||
if got := resp.Header.Get("Accept-Ranges"); got != "bytes" {
|
||||
t.Errorf("range miss GET: expected Accept-Ranges bytes, got %q", got)
|
||||
}
|
||||
|
||||
// Range must have been stripped for the upstream fetch (full file cached).
|
||||
if upstreamSawRange.Load() {
|
||||
t.Error("upstream received a Range header; Range must be stripped so the full object is cached")
|
||||
}
|
||||
if got := upstreamCalls.Load(); got != 1 {
|
||||
t.Errorf("upstream hit %d times, want exactly 1", got)
|
||||
}
|
||||
|
||||
// range_upstream incremented, range_cache untouched.
|
||||
stats := sc.GetMetrics()
|
||||
if stats.RangeUpstream != 1 {
|
||||
t.Errorf("expected RangeUpstream == 1 after range MISS, got %d", stats.RangeUpstream)
|
||||
}
|
||||
if stats.RangeCache != 0 {
|
||||
t.Errorf("expected RangeCache == 0 (no range hit yet), got %d", stats.RangeCache)
|
||||
}
|
||||
if stats.CacheMisses < 1 {
|
||||
t.Errorf("expected CacheMisses >= 1, got %d", stats.CacheMisses)
|
||||
}
|
||||
// BytesServed for the range miss: only the 4 range bytes, not the 16-byte fetch.
|
||||
if stats.TotalBytesServed != 4 {
|
||||
t.Errorf("expected TotalBytesServed == 4 (range bytes only), got %d", stats.TotalBytesServed)
|
||||
}
|
||||
|
||||
// The FULL object must have been cached: a subsequent full GET is a HIT with the
|
||||
// complete 16-byte body.
|
||||
req3, err := http.NewRequest("GET", srv.URL+"/depot/rangetest/chunk2", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("NewRequest: %v", err)
|
||||
}
|
||||
req3.Header.Set("User-Agent", "Valve/Steam HTTP Client 1.0")
|
||||
resp3, err := c.Do(req3)
|
||||
if err != nil {
|
||||
t.Fatalf("full GET after range miss: %v", err)
|
||||
}
|
||||
fullData, err := io.ReadAll(resp3.Body)
|
||||
_ = resp3.Body.Close()
|
||||
if err != nil {
|
||||
t.Fatalf("read full body: %v", err)
|
||||
}
|
||||
if resp3.StatusCode != http.StatusOK {
|
||||
t.Fatalf("full GET after range miss: expected 200, got %d", resp3.StatusCode)
|
||||
}
|
||||
if got := resp3.Header.Get("X-LanCache-Status"); got != "HIT" {
|
||||
t.Errorf("full GET after range miss: expected X-LanCache-Status HIT, got %q", got)
|
||||
}
|
||||
if len(fullData) != len(body) || string(fullData) != string(body) {
|
||||
t.Errorf("full GET after range miss: expected full %d-byte body, got %d bytes", len(body), len(fullData))
|
||||
}
|
||||
if got := upstreamCalls.Load(); got != 1 {
|
||||
t.Errorf("upstream hit %d times after HIT, want still 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRangeMissInvalidRange416 verifies that an unsatisfiable Range on a cold key
|
||||
// fetches upstream, returns 416 (as on the HIT path), and does not count range_upstream.
|
||||
func TestRangeMissInvalidRange416(t *testing.T) {
|
||||
body := []byte("0123456789abcdef") // 16 bytes
|
||||
var upstreamCalls atomic.Int64
|
||||
f := func(w http.ResponseWriter, r *http.Request) {
|
||||
upstreamCalls.Add(1)
|
||||
_, _ = w.Write(body)
|
||||
}
|
||||
sc, _ := newTestCacheWithFakeUpstream(t, f, "1MB", "0")
|
||||
srv := newCacheServer(t, sc)
|
||||
c := &http.Client{Timeout: 5 * time.Second}
|
||||
|
||||
req, err := http.NewRequest("GET", srv.URL+"/depot/rangetest/chunk3", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("NewRequest: %v", err)
|
||||
}
|
||||
req.Header.Set("User-Agent", "Valve/Steam HTTP Client 1.0")
|
||||
req.Header.Set("Range", "bytes=100-200")
|
||||
resp, err := c.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("invalid range GET: %v", err)
|
||||
}
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
_ = resp.Body.Close()
|
||||
if err != nil {
|
||||
t.Fatalf("read body: %v", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusRequestedRangeNotSatisfiable {
|
||||
t.Fatalf("invalid range GET: expected 416, got %d", resp.StatusCode)
|
||||
}
|
||||
if len(data) != 0 {
|
||||
t.Errorf("invalid range GET: expected empty body, got %d bytes", len(data))
|
||||
}
|
||||
if got := resp.Header.Get("Content-Range"); got != "bytes */16" {
|
||||
t.Errorf("invalid range GET: expected Content-Range bytes */16, got %q", got)
|
||||
}
|
||||
if got := upstreamCalls.Load(); got != 1 {
|
||||
t.Errorf("upstream hit %d times, want exactly 1 (fetch happens, then 416 to client)", got)
|
||||
}
|
||||
stats := sc.GetMetrics()
|
||||
if stats.RangeUpstream != 0 {
|
||||
t.Errorf("expected RangeUpstream == 0 for unsatisfiable range, got %d", stats.RangeUpstream)
|
||||
}
|
||||
if stats.RangeCache != 0 {
|
||||
t.Errorf("expected RangeCache == 0, got %d", stats.RangeCache)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRangeMetricsWriteText verifies /metrics emits the range_cache and range_upstream
|
||||
// lines with the expected values after a range HIT and a range MISS.
|
||||
func TestRangeMetricsWriteText(t *testing.T) {
|
||||
body := []byte("0123456789abcdef")
|
||||
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}
|
||||
|
||||
get := func(path, rangeHeader string) int {
|
||||
t.Helper()
|
||||
req, err := http.NewRequest("GET", srv.URL+path, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("NewRequest: %v", err)
|
||||
}
|
||||
req.Header.Set("User-Agent", "Valve/Steam HTTP Client 1.0")
|
||||
if rangeHeader != "" {
|
||||
req.Header.Set("Range", rangeHeader)
|
||||
}
|
||||
resp, err := c.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("GET %s: %v", path, err)
|
||||
}
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
_ = resp.Body.Close()
|
||||
return resp.StatusCode
|
||||
}
|
||||
|
||||
// Range MISS on cold key -> range_upstream; warm it; range HIT -> range_cache.
|
||||
if code := get("/depot/rangetest/wt/1", "bytes=0-3"); code != http.StatusPartialContent {
|
||||
t.Fatalf("range miss: expected 206, got %d", code)
|
||||
}
|
||||
if code := get("/depot/rangetest/wt/2", ""); code != http.StatusOK {
|
||||
t.Fatalf("warm miss: expected 200, got %d", code)
|
||||
}
|
||||
if code := get("/depot/rangetest/wt/2", "bytes=8-11"); code != http.StatusPartialContent {
|
||||
t.Fatalf("range hit: expected 206, got %d", code)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("GET", 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)
|
||||
}
|
||||
text := string(out)
|
||||
if !strings.Contains(text, "range_cache 1\n") {
|
||||
t.Errorf("/metrics missing 'range_cache 1' line:\n%s", text)
|
||||
}
|
||||
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:
|
||||
// valid Range yields 206 with the exact slice + metrics; invalid Range yields 416
|
||||
// with no range metrics.
|
||||
func TestStreamCachedResponseRange206(t *testing.T) {
|
||||
body := []byte("0123456789abcdef") // 16 bytes
|
||||
raw := append([]byte("HTTP/1.1 200 OK\r\nContent-Type: application/octet-stream\r\n\r\n"), body...)
|
||||
serialized, err := serializeRawResponse(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("serialize cache file: %v", err)
|
||||
}
|
||||
cf, err := deserializeCacheFile(serialized)
|
||||
if err != nil {
|
||||
t.Fatalf("build cache file: %v", err)
|
||||
}
|
||||
|
||||
sc, _ := newTestCacheWithFakeUpstream(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte("x"))
|
||||
}, "1MB", "0")
|
||||
sc.ResetMetrics()
|
||||
|
||||
// Valid range -> 206 + slice + metrics.
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "/depot/rangetest/chunk", nil)
|
||||
req.Header.Set("Range", "bytes=4-7")
|
||||
sc.streamCachedResponse(rec, req, cf, "steam/testkey", "127.0.0.1", time.Now())
|
||||
|
||||
if rec.Code != http.StatusPartialContent {
|
||||
t.Fatalf("expected 206, got %d", rec.Code)
|
||||
}
|
||||
if rec.Body.String() != "4567" {
|
||||
t.Errorf("expected body %q, got %q", "4567", rec.Body.String())
|
||||
}
|
||||
if got := rec.Header().Get("Content-Range"); got != "bytes 4-7/16" {
|
||||
t.Errorf("expected Content-Range bytes 4-7/16, got %q", got)
|
||||
}
|
||||
if got := rec.Header().Get("X-LanCache-Status"); got != "HIT" {
|
||||
t.Errorf("expected X-LanCache-Status HIT, got %q", got)
|
||||
}
|
||||
stats := sc.GetMetrics()
|
||||
if stats.RangeCache != 1 {
|
||||
t.Errorf("expected RangeCache == 1, got %d", stats.RangeCache)
|
||||
}
|
||||
if stats.TotalBytesServed != 4 {
|
||||
t.Errorf("expected TotalBytesServed == 4 (range bytes), got %d", stats.TotalBytesServed)
|
||||
}
|
||||
if stats.TotalBytesSaved != 4 {
|
||||
t.Errorf("expected TotalBytesSaved == 4 (range bytes), got %d", stats.TotalBytesSaved)
|
||||
}
|
||||
|
||||
// Invalid range -> 416, no range metrics, no bytes served.
|
||||
rec2 := httptest.NewRecorder()
|
||||
req2 := httptest.NewRequest("GET", "/depot/rangetest/chunk", nil)
|
||||
req2.Header.Set("Range", "bytes=100-200")
|
||||
sc.streamCachedResponse(rec2, req2, cf, "steam/testkey", "127.0.0.1", time.Now())
|
||||
|
||||
if rec2.Code != http.StatusRequestedRangeNotSatisfiable {
|
||||
t.Fatalf("expected 416, got %d", rec2.Code)
|
||||
}
|
||||
if got := rec2.Header().Get("Content-Range"); got != "bytes */16" {
|
||||
t.Errorf("expected Content-Range bytes */16, got %q", got)
|
||||
}
|
||||
stats = sc.GetMetrics()
|
||||
if stats.RangeCache != 1 {
|
||||
t.Errorf("RangeCache must stay 1 after unsatisfiable range, got %d", stats.RangeCache)
|
||||
}
|
||||
if stats.TotalBytesServed != 4 {
|
||||
t.Errorf("TotalBytesServed must stay 4 after 416, got %d", stats.TotalBytesServed)
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
@@ -163,3 +164,44 @@ func generateServiceCacheKey(urlPath string, servicePrefix string) (string, erro
|
||||
}
|
||||
return servicePrefix + "/" + hash, nil
|
||||
}
|
||||
|
||||
// requestHostName strips a port and brackets from an HTTP Host header.
|
||||
func requestHostName(host string) string {
|
||||
host = strings.TrimSpace(host)
|
||||
if host == "" {
|
||||
return ""
|
||||
}
|
||||
if h, _, err := net.SplitHostPort(host); err == nil {
|
||||
host = h
|
||||
}
|
||||
return strings.Trim(host, "[]")
|
||||
}
|
||||
|
||||
func hostIsLiteralIP(host string) bool {
|
||||
return net.ParseIP(requestHostName(host)) != nil
|
||||
}
|
||||
|
||||
// defaultDirectFetchSuffixes are CDN names Steam actually uses. Applied only when
|
||||
// no configured upstream is set and the request Host is used as the fetch target.
|
||||
var defaultDirectFetchSuffixes = []string{
|
||||
"steamcontent.com",
|
||||
"steampowered.com",
|
||||
"steamstatic.com",
|
||||
}
|
||||
|
||||
// hostAllowedForDirectFetch reports whether Host may be used as an origin when
|
||||
// upstream is empty. Literal IPs are rejected (LAN/metadata SSRF). Names must
|
||||
// be Steam CDN suffixes so a spoofed User-Agent cannot turn the cache into an
|
||||
// open reverse proxy.
|
||||
func hostAllowedForDirectFetch(host string) bool {
|
||||
name := strings.ToLower(requestHostName(host))
|
||||
if name == "" || hostIsLiteralIP(host) {
|
||||
return false
|
||||
}
|
||||
for _, suf := range defaultDirectFetchSuffixes {
|
||||
if name == suf || strings.HasSuffix(name, "."+suf) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
+72
-11
@@ -55,10 +55,17 @@ 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
|
||||
|
||||
// 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
|
||||
serviceManager *ServiceManager
|
||||
|
||||
@@ -71,14 +78,17 @@ type SteamCache struct {
|
||||
processor *requestProcessor
|
||||
}
|
||||
|
||||
// DefaultNegativeTTL is used when cache.negative_ttl / Options.NegativeTTL is empty.
|
||||
const DefaultNegativeTTL = 5 * time.Minute
|
||||
|
||||
// New creates a new SteamCache instance.
|
||||
// Returns an error (instead of panicking) on invalid memorySize or diskSize strings.
|
||||
// Also validates maxObjectSize and accepts trustedProxies for X-Forwarded-For handling.
|
||||
// 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.
|
||||
// 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.
|
||||
func New(address string, memorySize string, diskSize string, diskPath, upstream, memoryGC, diskGC string, maxConcurrentRequests int64, maxRequestsPerClient int64, maxObjectSize string, trustedProxies []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)
|
||||
@@ -102,6 +112,22 @@ func New(address string, memorySize string, diskSize string, diskPath, upstream,
|
||||
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()
|
||||
|
||||
var m *memory.MemoryFS
|
||||
@@ -166,11 +192,13 @@ 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
|
||||
maxObjectSize: maxObjBytes,
|
||||
trustedProxies: trustedProxies,
|
||||
negativeTTL: negTTL,
|
||||
|
||||
// Initialize service management
|
||||
serviceManager: NewServiceManager(),
|
||||
@@ -200,23 +228,33 @@ func New(address string, memorySize string, diskSize string, diskPath, upstream,
|
||||
|
||||
if disksize == 0 && memorysize != 0 {
|
||||
// memory only mode - no disk
|
||||
sc.metrics.SetDiskTierReady(1) // no disk — N/A / not pending
|
||||
c.SetSlow(mgc)
|
||||
} else if disksize != 0 && memorysize == 0 {
|
||||
// disk only mode: delay attach until disk ready (pure-proxy during scan; Create returns ErrNotFound until slow tier Set)
|
||||
sc.metrics.SetDiskTierReady(0)
|
||||
logger.Logger.Info().Msg("Disk slow tier attach pending; Size barrier in progress")
|
||||
sc.wg.Add(1)
|
||||
go func() {
|
||||
defer sc.wg.Done()
|
||||
t0 := time.Now()
|
||||
_ = d.Size() // block on barrier per design (all Size callers during window do this; documented)
|
||||
select {
|
||||
case <-sc.shutdownCh:
|
||||
return // Shutdown raced; do not attach or SetSlow after stop
|
||||
default:
|
||||
c.SetSlow(dgc)
|
||||
sc.metrics.SetDiskTierReady(1)
|
||||
logger.Logger.Info().
|
||||
Dur("attach_delay", time.Since(t0)).
|
||||
Msg("Disk slow tier attached (disk-only mode); prior traffic had no disk tier")
|
||||
}
|
||||
}()
|
||||
} else if disksize != 0 && memorysize != 0 {
|
||||
// memory and disk mode: fast mem immediate, disk delayed (mem-only during scan)
|
||||
c.SetFast(mgc)
|
||||
sc.metrics.SetDiskTierReady(0)
|
||||
logger.Logger.Info().Msg("Disk slow tier attach pending; Size barrier in progress")
|
||||
sc.wg.Add(1)
|
||||
go func() {
|
||||
defer sc.wg.Done()
|
||||
@@ -227,6 +265,7 @@ func New(address string, memorySize string, diskSize string, diskPath, upstream,
|
||||
return
|
||||
default:
|
||||
c.SetSlow(dgc)
|
||||
sc.metrics.SetDiskTierReady(1)
|
||||
logger.Logger.Info().
|
||||
Dur("attach_delay", time.Since(t0)).
|
||||
Msg("Disk slow tier attached (mixed mode); prior traffic was memory-only")
|
||||
@@ -326,15 +365,18 @@ func (sc *SteamCache) Shutdown() {
|
||||
|
||||
// GetMetrics returns current metrics
|
||||
func (sc *SteamCache) GetMetrics() *metrics.Stats {
|
||||
// Update cache sizes
|
||||
if sc.memory != nil {
|
||||
sc.metrics.SetMemoryCacheSize(sc.memory.Size())
|
||||
sc.metrics.SetMemoryCacheCapacity(sc.memory.Capacity())
|
||||
}
|
||||
if sc.disk != nil {
|
||||
// Note: blocks on initDone (post-eviction state) for accurate post-attach size during long disk init window.
|
||||
// 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 {
|
||||
sc.metrics.SetDiskCacheSize(sc.disk.Size())
|
||||
}
|
||||
|
||||
return sc.metrics.GetStats()
|
||||
}
|
||||
|
||||
@@ -343,6 +385,26 @@ func (sc *SteamCache) ResetMetrics() {
|
||||
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.
|
||||
// Extracted to shrink New (Phase 3).
|
||||
func newHTTPTransport() *http.Transport {
|
||||
@@ -357,7 +419,7 @@ func newHTTPTransport() *http.Transport {
|
||||
DialContext: (&net.Dialer{
|
||||
Timeout: 10 * time.Second, // Faster connection timeout
|
||||
KeepAlive: 60 * time.Second, // Longer keep-alive
|
||||
DualStack: true, // Enable dual-stack (IPv4/IPv6)
|
||||
// Dual-stack Happy Eyeballs is the default since Go 1.12 (DualStack is deprecated).
|
||||
}).DialContext,
|
||||
|
||||
// Timeout optimizations
|
||||
@@ -387,11 +449,10 @@ func newHTTPClient(transport *http.Transport) *http.Client {
|
||||
Timeout: 60 * time.Second, // Optimized timeout for better responsiveness
|
||||
// Add redirect policy for better performance
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
// Limit redirects to prevent infinite loops
|
||||
if len(via) >= 10 {
|
||||
return http.ErrUseLastResponse
|
||||
}
|
||||
return nil
|
||||
// Do not follow redirects. Steam CDN chunk/manifest fetches are
|
||||
// expected to be 200; following Location would let an origin send
|
||||
// the cache at an arbitrary internal URL.
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
+471
-11
@@ -2,21 +2,25 @@
|
||||
package steamcache
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"s1d3sw1ped/steamcache2/steamcache/metrics"
|
||||
"s1d3sw1ped/steamcache2/vfs/disk"
|
||||
"s1d3sw1ped/steamcache2/vfs/eviction"
|
||||
"s1d3sw1ped/steamcache2/vfs/memory"
|
||||
"s1d3sw1ped/steamcache2/vfs/vfserror"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -24,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)
|
||||
}
|
||||
@@ -129,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)
|
||||
}
|
||||
@@ -372,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)
|
||||
}
|
||||
@@ -479,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)
|
||||
}
|
||||
@@ -498,6 +502,7 @@ func TestMetrics(t *testing.T) {
|
||||
sc.metrics.IncrementTotalRequests()
|
||||
sc.metrics.IncrementCacheHits()
|
||||
sc.metrics.IncrementCacheMisses()
|
||||
sc.metrics.IncrementNegativeCacheHits()
|
||||
sc.metrics.AddBytesServed(1024)
|
||||
sc.metrics.IncrementServiceRequests("steam")
|
||||
|
||||
@@ -511,6 +516,9 @@ func TestMetrics(t *testing.T) {
|
||||
if stats.CacheMisses != 1 {
|
||||
t.Error("Cache misses should be 1")
|
||||
}
|
||||
if stats.NegativeCacheHits != 1 {
|
||||
t.Error("Negative cache hits should be 1")
|
||||
}
|
||||
if stats.TotalBytesServed != 1024 {
|
||||
t.Error("Total bytes served should be 1024")
|
||||
}
|
||||
@@ -538,6 +546,9 @@ func TestMetrics(t *testing.T) {
|
||||
if stats.CacheHits != 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)
|
||||
rec := httptest.NewRecorder()
|
||||
@@ -548,6 +559,15 @@ func TestMetrics(t *testing.T) {
|
||||
if !bytes.Contains(rec.Body.Bytes(), []byte("total_requests")) {
|
||||
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
|
||||
@@ -567,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)
|
||||
}
|
||||
@@ -729,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)
|
||||
}
|
||||
@@ -793,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)
|
||||
}
|
||||
@@ -883,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")
|
||||
}
|
||||
@@ -904,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)
|
||||
}
|
||||
@@ -1036,20 +1056,29 @@ func TestP1_03_EvictionAlgorithmsDistinct(t *testing.T) {
|
||||
// TestDiskOnlyDelayedAttach covers pure disk-only mode (mem=0 + disk>0) hitting the exact delayed attach path.
|
||||
// During init window (pre Size barrier), TieredCache has no slow tier so Create returns ErrNotFound (proxy semantics, no disk caching).
|
||||
// Post-barrier + attach, Create succeeds. Uses real temp dir.
|
||||
// An init hold keeps the empty-dir attach from finishing before the pending assertions (CI race).
|
||||
func TestDiskOnlyDelayedAttach(t *testing.T) {
|
||||
t.Parallel()
|
||||
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)
|
||||
})
|
||||
|
||||
// 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)
|
||||
}
|
||||
t.Cleanup(func() { sc.Shutdown() })
|
||||
t.Cleanup(closeHold) // before Shutdown: attach is blocked in Size() until the hold closes
|
||||
|
||||
// Immediately in window: no slow tier attached yet -> Create must ErrNotFound (proxy, no disk write)
|
||||
_, err = sc.vfs.Create("during-init-key", 100)
|
||||
@@ -1057,6 +1086,13 @@ func TestDiskOnlyDelayedAttach(t *testing.T) {
|
||||
t.Errorf("during init window, expected ErrNotFound from disk-only tiered Create (no slow), got %v", err)
|
||||
}
|
||||
|
||||
// Disk tier is pending while the attach goroutine is in the Size barrier.
|
||||
// GetMetrics must return quickly (it skips disk.Size() while pending) and report 0.
|
||||
if got := sc.GetMetrics().DiskTierReady; got != 0 {
|
||||
t.Errorf("during pending attach, DiskTierReady=%d, want 0", got)
|
||||
}
|
||||
|
||||
closeHold()
|
||||
// Wait the barrier (exercises the attach go's Size wait)
|
||||
_ = sc.disk.Size()
|
||||
|
||||
@@ -1083,6 +1119,112 @@ func TestDiskOnlyDelayedAttach(t *testing.T) {
|
||||
} else {
|
||||
rc.Close()
|
||||
}
|
||||
|
||||
// After attach, the disk tier must be marked ready (1)
|
||||
if got := sc.GetMetrics().DiskTierReady; got != 1 {
|
||||
t.Errorf("post-attach DiskTierReady=%d, want 1 (ready)", got)
|
||||
}
|
||||
|
||||
// /metrics text output includes the disk_tier_ready line
|
||||
rec := httptest.NewRecorder()
|
||||
metrics.WriteText(rec, sc.GetMetrics())
|
||||
if !bytes.Contains(rec.Body.Bytes(), []byte("disk_tier_ready 1")) {
|
||||
t.Errorf("WriteText output missing \"disk_tier_ready 1\": %q", rec.Body.String())
|
||||
}
|
||||
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, "", "", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("New memory-only: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { sc.Shutdown() })
|
||||
|
||||
if got := sc.GetMetrics().DiskTierReady; got != 1 {
|
||||
t.Errorf("DiskTierReady=%d, want 1 (memory-only = N/A/not pending)", got)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest("GET", "/lancache-heartbeat", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
sc.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusNoContent {
|
||||
t.Errorf("heartbeat status=%d, want 204", rec.Code)
|
||||
}
|
||||
if got := rec.Header().Get("X-SteamCache-Disk-Tier"); got != "disabled" {
|
||||
t.Errorf("X-SteamCache-Disk-Tier=%q, want disabled", got)
|
||||
}
|
||||
if got := rec.Header().Get("X-LanCache-Processed-By"); got != "SteamCache2" {
|
||||
t.Errorf("X-LanCache-Processed-By=%q, want SteamCache2", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDiskTierSignalMixedPendingReady covers mixed mode: DiskTierReady=0 (header
|
||||
// pending) while the disk attach is in the Size barrier, then DiskTierReady=1
|
||||
// (header ready) after the barrier opens and the attach goroutine sets SetSlow.
|
||||
// An init hold keeps the empty-dir attach from finishing between the pending
|
||||
// metric check and the heartbeat, which otherwise races under CI load.
|
||||
func TestDiskTierSignalMixedPendingReady(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 until closeHold; attach cannot finish.
|
||||
if got := sc.GetMetrics().DiskTierReady; got != 0 {
|
||||
t.Errorf("immediate DiskTierReady=%d, want 0 (pending)", got)
|
||||
}
|
||||
req := httptest.NewRequest("GET", "/lancache-heartbeat", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
sc.ServeHTTP(rec, req)
|
||||
if got := rec.Header().Get("X-SteamCache-Disk-Tier"); got != "pending" {
|
||||
t.Errorf("heartbeat header=%q, want pending", got)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
rec2 := httptest.NewRecorder()
|
||||
sc.ServeHTTP(rec2, httptest.NewRequest("GET", "/lancache-heartbeat", nil))
|
||||
if got := rec2.Header().Get("X-SteamCache-Disk-Tier"); got != "ready" {
|
||||
t.Errorf("heartbeat header=%q, want ready", got)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Phase 2: narrow black-box tests for the new wrapper types ---
|
||||
@@ -1165,3 +1307,321 @@ func TestClientRateLimiter_BlackBox(t *testing.T) {
|
||||
t.Error("different clients must have distinct limiters")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHostAllowedForDirectFetch(t *testing.T) {
|
||||
allowed := []string{
|
||||
"lancache.steamcontent.com",
|
||||
"cache1-iad1.steamcontent.com:443",
|
||||
"steamcontent.com",
|
||||
"content.steampowered.com",
|
||||
"cdn.steamstatic.com",
|
||||
}
|
||||
denied := []string{
|
||||
"",
|
||||
"127.0.0.1",
|
||||
"127.0.0.1:80",
|
||||
"[::1]:80",
|
||||
"192.168.1.1",
|
||||
"169.254.169.254",
|
||||
"evil.example",
|
||||
"example.com",
|
||||
"notsteamcontent.com",
|
||||
}
|
||||
for _, h := range allowed {
|
||||
if !hostAllowedForDirectFetch(h) {
|
||||
t.Errorf("expected allowed: %q", h)
|
||||
}
|
||||
}
|
||||
for _, h := range denied {
|
||||
if hostAllowedForDirectFetch(h) {
|
||||
t.Errorf("expected denied: %q", h)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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, "", "", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { sc.Shutdown() })
|
||||
|
||||
req := httptest.NewRequest("GET", "/depot/ssrf/chunk", nil)
|
||||
req.Host = "127.0.0.1"
|
||||
req.Header.Set("User-Agent", "Valve/Steam HTTP Client 1.0")
|
||||
rec := httptest.NewRecorder()
|
||||
sc.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("IP Host: expected 400, got %d", rec.Code)
|
||||
}
|
||||
|
||||
req2 := httptest.NewRequest("GET", "/depot/ssrf/chunk2", nil)
|
||||
req2.Host = "evil.example"
|
||||
req2.Header.Set("User-Agent", "Valve/Steam HTTP Client 1.0")
|
||||
rec2 := httptest.NewRecorder()
|
||||
sc.ServeHTTP(rec2, req2)
|
||||
if rec2.Code != http.StatusBadRequest {
|
||||
t.Errorf("non-CDN Host: expected 400, got %d", rec2.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCacheKeySharedAcrossCDNHostAliases verifies that one cache entry serves
|
||||
// the same depot object across different Steam CDN host aliases: the key uses
|
||||
// only the request path (never the Host header or an absolute-form target
|
||||
// host), so hits climb instead of re-stamping upstream per host rotation.
|
||||
func TestCacheKeySharedAcrossCDNHostAliases(t *testing.T) {
|
||||
body := []byte("depot chunk body for host-alias keying")
|
||||
var upstreamCalls atomic.Int64
|
||||
f := func(w http.ResponseWriter, r *http.Request) {
|
||||
upstreamCalls.Add(1)
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
_, _ = w.Write(body)
|
||||
}
|
||||
sc, _ := newTestCacheWithFakeUpstream(t, f, "1MB", "0")
|
||||
srv := newCacheServer(t, sc)
|
||||
const depotPath = "/depot/1684171/chunk/abc123"
|
||||
const ua = "Valve/Steam HTTP Client 1.0"
|
||||
c := &http.Client{Timeout: 5 * time.Second}
|
||||
|
||||
// 1) MISS under the first CDN alias (origin-form target, Host: cdn1).
|
||||
req1, err := http.NewRequest("GET", srv.URL+depotPath, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req1.Host = "cdn1.steamcontent.com"
|
||||
req1.Header.Set("User-Agent", ua)
|
||||
resp1, err := c.Do(req1)
|
||||
if err != nil {
|
||||
t.Fatalf("host-alias MISS request: %v", err)
|
||||
}
|
||||
data1, err := io.ReadAll(resp1.Body)
|
||||
resp1.Body.Close()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp1.StatusCode != http.StatusOK {
|
||||
t.Fatalf("host-alias MISS: expected 200, got %d", resp1.StatusCode)
|
||||
}
|
||||
if got := resp1.Header.Get("X-LanCache-Status"); got != "MISS" {
|
||||
t.Fatalf("host-alias MISS: expected X-LanCache-Status MISS, got %q", got)
|
||||
}
|
||||
if !bytes.Equal(data1, body) {
|
||||
t.Fatalf("host-alias MISS: body mismatch: got %q", data1)
|
||||
}
|
||||
|
||||
// Bounded wait for the entry to be visible before hitting the next alias
|
||||
// (the MISS handler streams the body to the client before the VFS write).
|
||||
key, err := generateServiceCacheKey(depotPath, "steam")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for {
|
||||
if rc, e := sc.vfs.Open(key); e == nil {
|
||||
_ = rc.Close()
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("cache entry %q not visible after MISS", key)
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
|
||||
// 2) Same depot path under the second CDN alias (Host header only) -> HIT.
|
||||
req2, err := http.NewRequest("GET", srv.URL+depotPath, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req2.Host = "cdn2.steamcontent.com"
|
||||
req2.Header.Set("User-Agent", ua)
|
||||
resp2, err := c.Do(req2)
|
||||
if err != nil {
|
||||
t.Fatalf("second host-alias request: %v", err)
|
||||
}
|
||||
data2, err := io.ReadAll(resp2.Body)
|
||||
resp2.Body.Close()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp2.StatusCode != http.StatusOK {
|
||||
t.Fatalf("second host-alias: expected 200, got %d", resp2.StatusCode)
|
||||
}
|
||||
if got := resp2.Header.Get("X-LanCache-Status"); got != "HIT" {
|
||||
t.Fatalf("second host-alias: expected X-LanCache-Status HIT, got %q", got)
|
||||
}
|
||||
if !bytes.Equal(data2, body) {
|
||||
t.Fatalf("second host-alias: body mismatch: got %q", data2)
|
||||
}
|
||||
|
||||
// 3) Same depot path with an absolute-form target embedding a third CDN
|
||||
// hostname in the URL itself -> still a HIT on the same entry.
|
||||
// (Go's http client always sends origin-form targets, so use raw HTTP.)
|
||||
conn, err := net.Dial("tcp", srv.Listener.Addr().String())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer conn.Close()
|
||||
rawRequest := "GET http://cdn3.steamcontent.com" + depotPath + " HTTP/1.1\r\n" +
|
||||
"Host: cdn3.steamcontent.com\r\n" +
|
||||
"User-Agent: " + ua + "\r\n" +
|
||||
"Connection: close\r\n\r\n"
|
||||
if _, err := conn.Write([]byte(rawRequest)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rawReq, err := http.NewRequest("GET", "http://cdn3.steamcontent.com"+depotPath, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp3, err := http.ReadResponse(bufio.NewReader(conn), rawReq)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp3.Body.Close()
|
||||
data3, err := io.ReadAll(resp3.Body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp3.StatusCode != http.StatusOK {
|
||||
t.Fatalf("absolute-form host-alias: expected 200, got %d", resp3.StatusCode)
|
||||
}
|
||||
if got := resp3.Header.Get("X-LanCache-Status"); got != "HIT" {
|
||||
t.Fatalf("absolute-form host-alias: expected X-LanCache-Status HIT, got %q", got)
|
||||
}
|
||||
if !bytes.Equal(data3, body) {
|
||||
t.Fatalf("absolute-form host-alias: body mismatch: got %q", data3)
|
||||
}
|
||||
|
||||
// All three aliases must have shared one upstream fetch.
|
||||
if got := upstreamCalls.Load(); got != 1 {
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
Vendored
+4
-2
@@ -186,7 +186,7 @@ func (tc *TieredCache) Capacity() int64 {
|
||||
func (tc *TieredCache) promoteToFast(key string, reader io.ReadCloser) {
|
||||
defer func() { _ = reader.Close() }() // best-effort close; error secondary to promotion attempt (async best-effort path)
|
||||
|
||||
// Get file info from slow tier to determine size
|
||||
// Size for the space/ReadAll guards comes from a Stat snapshot, not the live in-map FileInfo.
|
||||
var size int64
|
||||
if slow := tc.slow.Load(); slow != nil {
|
||||
if vfs, ok := slow.(vfs.VFS); ok {
|
||||
@@ -210,7 +210,7 @@ func (tc *TieredCache) promoteToFast(key string, reader io.ReadCloser) {
|
||||
}
|
||||
|
||||
// Guard promotion ReadAll using already-fetched size (in addition to space check above)
|
||||
if size > 0 && size > (1<<30) { // conservative 1GB hard limit on promotion reads (aligns with typical max_object_size)
|
||||
if size > (1 << 30) { // conservative 1GB hard limit on promotion reads (aligns with typical max_object_size)
|
||||
return
|
||||
}
|
||||
// Read the entire file content
|
||||
@@ -218,6 +218,8 @@ func (tc *TieredCache) promoteToFast(key string, reader io.ReadCloser) {
|
||||
if err != nil {
|
||||
return // Skip promotion if read fails
|
||||
}
|
||||
// Create with the bytes we actually hold so we never reuse a live FileInfo.Size.
|
||||
size = int64(len(content))
|
||||
|
||||
// Create the file in fast tier
|
||||
if fast := tc.fast.Load(); fast != nil {
|
||||
|
||||
+83
-27
@@ -45,6 +45,25 @@ type DiskFS struct {
|
||||
initCloseOnce sync.Once
|
||||
startupEvict func(vfs.VFS, uint) uint // passed to New (via gc.GetGCAlgorithm); invoked as last step of bg init if over cap (no post-ctor race)
|
||||
metrics *metrics.Metrics
|
||||
// initHold, if non-nil, is received on before closing initDone (test pending-window hold).
|
||||
initHold <-chan struct{}
|
||||
}
|
||||
|
||||
// initHolds is a per-root registry of optional init holds (root path -> <-chan struct{}).
|
||||
// Tests call RegisterInitHold before New so that instance copies the channel and waits
|
||||
// before closing initDone; production never registers, so init is unchanged.
|
||||
var initHolds sync.Map
|
||||
|
||||
// RegisterInitHold registers a channel that DiskFS.New for this root copies onto that
|
||||
// instance. calculateSizeAndPopulateIndex receives on it before closing initDone, so
|
||||
// tests can observe the pending-attach window. Other DiskFS roots are unaffected.
|
||||
func RegisterInitHold(root string, ch <-chan struct{}) {
|
||||
initHolds.Store(root, ch)
|
||||
}
|
||||
|
||||
// ClearInitHold removes a previously registered hold for root.
|
||||
func ClearInitHold(root string) {
|
||||
initHolds.Delete(root)
|
||||
}
|
||||
|
||||
// shardPath converts a Steam cache key to a sharded directory path to reduce inode pressure
|
||||
@@ -129,6 +148,12 @@ func New(root string, capacity int64, evict func(vfs.VFS, uint) uint) (*DiskFS,
|
||||
startupEvict: evict,
|
||||
}
|
||||
|
||||
if v, ok := initHolds.Load(root); ok {
|
||||
if ch, ok := v.(<-chan struct{}); ok {
|
||||
d.initHold = ch
|
||||
}
|
||||
}
|
||||
|
||||
d.initDone = make(chan struct{})
|
||||
// Launch heavy population asynchronously so New returns fast (scans millions of files without blocking ctor or using O(N) temp RAM).
|
||||
// The initDone barrier ensures first Size() and subsequent ops (including late tier attach) see fully populated + post-eviction state.
|
||||
@@ -152,7 +177,7 @@ func (d *DiskFS) calculateSizeAndPopulateIndex() {
|
||||
if r := recover(); r != nil {
|
||||
logger.Logger.Error().Interface("recovered_panic", r).Msg("calculateSizeAndPopulateIndex panicked; ensuring initDone closed to unblock Size waiters and prevent hang")
|
||||
}
|
||||
d.initCloseOnce.Do(func() { close(d.initDone) })
|
||||
d.closeInitDone()
|
||||
}()
|
||||
|
||||
tstart := time.Now()
|
||||
@@ -243,20 +268,42 @@ func (d *DiskFS) calculateSizeAndPopulateIndex() {
|
||||
|
||||
// Signal readiness: Size() and callers (late tier attach + Evict*) now see correct populated + post-eviction state.
|
||||
// Use Once (recover path also uses it) to guarantee exactly one close even under panic.
|
||||
d.initCloseOnce.Do(func() { close(d.initDone) })
|
||||
d.closeInitDone()
|
||||
}
|
||||
|
||||
// closeInitDone receives on a copied test hold (if any) then closes initDone once.
|
||||
// Both the normal end of calculateSizeAndPopulateIndex and the panic-recovery defer
|
||||
// call this so Size() waiters unblock in either path.
|
||||
func (d *DiskFS) closeInitDone() {
|
||||
d.initCloseOnce.Do(func() {
|
||||
if d.initHold != nil {
|
||||
<-d.initHold
|
||||
}
|
||||
close(d.initDone)
|
||||
})
|
||||
}
|
||||
|
||||
// insertBatch populates info/LRU under lock for a bounded batch (follows maxEvictBatch pattern for short critical sections).
|
||||
// Size is incremented here only for files actually added (prevents double-count vs. concurrent Create during window).
|
||||
// Fail-closed: re-stat each path under d.mu and skip if the file is gone. Create does not wait on
|
||||
// initDone, so a file the scanner observed can be Evict/Delete'd (info + os.Remove) before this
|
||||
// insert runs. Inserting without a live-file check would resurrect the key in d.info and make
|
||||
// Stat succeed while os.Stat fails.
|
||||
func (d *DiskFS) insertBatch(batch []discoveredFile) {
|
||||
d.mu.Lock()
|
||||
for _, df := range batch {
|
||||
if _, exists := d.info[df.key]; !exists {
|
||||
fi := vfs.NewFileInfoFromOS(df.osInfo, df.key)
|
||||
d.info[df.key] = fi
|
||||
d.LRU.Add(df.key, fi)
|
||||
d.size += df.size
|
||||
if _, exists := d.info[df.key]; exists {
|
||||
continue
|
||||
}
|
||||
path := d.pathForKey(df.key)
|
||||
st, err := os.Stat(path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
fi := vfs.NewFileInfoFromOS(st, df.key)
|
||||
d.info[df.key] = fi
|
||||
d.LRU.Add(df.key, fi)
|
||||
d.size += st.Size()
|
||||
}
|
||||
d.mu.Unlock()
|
||||
}
|
||||
@@ -393,11 +440,13 @@ func (d *DiskFS) Create(key string, size int64) (io.WriteCloser, error) {
|
||||
dir := filepath.Dir(path)
|
||||
// 0700 (not 0755): per-shard cache dirs hold untrusted CDN content; restrict to owner only (G301 addressed).
|
||||
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||
d.recordIfNoSpace(err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
file, err := os.Create(path) // #nosec G304 -- path built by pathForKey from sanitized (Clean, no ..) hash-derived key under trusted disk.root; no untrusted file inclusion
|
||||
if err != nil {
|
||||
d.recordIfNoSpace(err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -428,7 +477,19 @@ type diskWriteCloser struct {
|
||||
}
|
||||
|
||||
func (dwc *diskWriteCloser) Write(p []byte) (n int, err error) {
|
||||
return dwc.file.Write(p)
|
||||
n, err = dwc.file.Write(p)
|
||||
if err != nil {
|
||||
dwc.disk.recordIfNoSpace(err)
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
|
||||
// recordIfNoSpace increments capacity_pressure_events and logs when err is ENOSPC (or Windows disk-full).
|
||||
func (d *DiskFS) recordIfNoSpace(err error) {
|
||||
if !isNoSpaceError(err) {
|
||||
return
|
||||
}
|
||||
metrics.NoteNoSpace(d.metrics, err)
|
||||
}
|
||||
|
||||
func (dwc *diskWriteCloser) Close() error {
|
||||
@@ -602,7 +663,9 @@ func (d *DiskFS) Delete(key string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stat returns file information with lazy discovery
|
||||
// Stat returns a snapshot of file information with lazy discovery.
|
||||
// The returned *FileInfo is not the live cache entry; Close may update Size
|
||||
// on the in-map object under d.mu.
|
||||
func (d *DiskFS) Stat(key string) (*vfs.FileInfo, error) {
|
||||
if key == "" {
|
||||
return nil, vfserror.ErrInvalidKey
|
||||
@@ -617,9 +680,10 @@ func (d *DiskFS) Stat(key string) (*vfs.FileInfo, error) {
|
||||
keyMu.RLock()
|
||||
d.mu.RLock()
|
||||
if fi, ok := d.info[key]; ok {
|
||||
snap := fi.Clone()
|
||||
d.mu.RUnlock()
|
||||
keyMu.RUnlock()
|
||||
return fi, nil
|
||||
return snap, nil
|
||||
}
|
||||
d.mu.RUnlock()
|
||||
keyMu.RUnlock()
|
||||
@@ -639,8 +703,9 @@ func (d *DiskFS) Stat(key string) (*vfs.FileInfo, error) {
|
||||
// Double-check after acquiring write lock
|
||||
d.mu.Lock()
|
||||
if fi, ok := d.info[key]; ok {
|
||||
snap := fi.Clone()
|
||||
d.mu.Unlock()
|
||||
return fi, nil
|
||||
return snap, nil
|
||||
}
|
||||
|
||||
// Re-verify the file still exists on disk under the lock before inserting.
|
||||
@@ -659,9 +724,10 @@ func (d *DiskFS) Stat(key string) (*vfs.FileInfo, error) {
|
||||
fi.UpdateAccessBatched(d.timeUpdater)
|
||||
// Note: size not updated on lazy discovery (preserves prior behavior; initial on-disk accounted via bg populate at New time,
|
||||
// subsequent files come via Create which accounts size).
|
||||
snap := fi.Clone()
|
||||
d.mu.Unlock()
|
||||
|
||||
return fi, nil
|
||||
return snap, nil
|
||||
}
|
||||
|
||||
// EvictLRU evicts the least recently used files to free up space
|
||||
@@ -707,9 +773,7 @@ func (d *DiskFS) EvictLRU(bytesNeeded uint) uint {
|
||||
}
|
||||
d.mu.Unlock()
|
||||
|
||||
if d.metrics != nil && evicted > 0 {
|
||||
d.metrics.IncrementEvictions()
|
||||
}
|
||||
metrics.NoteSoftEviction(d.metrics, "disk", evicted)
|
||||
return evicted
|
||||
}
|
||||
|
||||
@@ -760,9 +824,7 @@ func (d *DiskFS) EvictBySize(bytesNeeded uint, ascending bool) uint {
|
||||
}
|
||||
d.mu.Unlock()
|
||||
|
||||
if d.metrics != nil && evicted > 0 {
|
||||
d.metrics.IncrementEvictions()
|
||||
}
|
||||
metrics.NoteSoftEviction(d.metrics, "disk", evicted)
|
||||
return evicted
|
||||
}
|
||||
|
||||
@@ -811,9 +873,7 @@ func (d *DiskFS) EvictFIFO(bytesNeeded uint) uint {
|
||||
}
|
||||
d.mu.Unlock()
|
||||
|
||||
if d.metrics != nil && evicted > 0 {
|
||||
d.metrics.IncrementEvictions()
|
||||
}
|
||||
metrics.NoteSoftEviction(d.metrics, "disk", evicted)
|
||||
return evicted
|
||||
}
|
||||
|
||||
@@ -867,9 +927,7 @@ func (d *DiskFS) EvictLFU(bytesNeeded uint) uint {
|
||||
}
|
||||
d.mu.Unlock()
|
||||
|
||||
if d.metrics != nil && evicted > 0 {
|
||||
d.metrics.IncrementEvictions()
|
||||
}
|
||||
metrics.NoteSoftEviction(d.metrics, "disk", evicted)
|
||||
return evicted
|
||||
}
|
||||
|
||||
@@ -924,8 +982,6 @@ func (d *DiskFS) EvictHybrid(bytesNeeded uint) uint {
|
||||
}
|
||||
d.mu.Unlock()
|
||||
|
||||
if d.metrics != nil && evicted > 0 {
|
||||
d.metrics.IncrementEvictions()
|
||||
}
|
||||
metrics.NoteSoftEviction(d.metrics, "disk", evicted)
|
||||
return evicted
|
||||
}
|
||||
|
||||
+176
-41
@@ -11,6 +11,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"s1d3sw1ped/steamcache2/steamcache/metrics"
|
||||
"s1d3sw1ped/steamcache2/vfs"
|
||||
)
|
||||
|
||||
@@ -122,6 +123,42 @@ func TestDiskFS_InitPopulatesIndexOnRestart(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiskFS_CapacityPressureOnEvict(t *testing.T) {
|
||||
t.Parallel()
|
||||
td := t.TempDir()
|
||||
d, err := New(td, 500, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = d.Size()
|
||||
met := metrics.NewMetrics()
|
||||
d.SetMetrics(met)
|
||||
for i := 0; i < 3; i++ {
|
||||
k := "f" + string(rune('0'+i))
|
||||
w, cerr := d.Create(k, 200)
|
||||
if cerr != nil {
|
||||
t.Fatal(cerr)
|
||||
}
|
||||
if _, werr := w.Write(make([]byte, 200)); werr != nil {
|
||||
t.Fatal(werr)
|
||||
}
|
||||
if cerr := w.Close(); cerr != nil {
|
||||
t.Fatal(cerr)
|
||||
}
|
||||
}
|
||||
evicted := d.EvictLRU(100)
|
||||
if evicted == 0 {
|
||||
t.Fatalf("expected eviction under cap, size=%d cap=%d", d.Size(), d.Capacity())
|
||||
}
|
||||
st := met.GetStats()
|
||||
if st.Evictions == 0 {
|
||||
t.Error("evictions counter not incremented under disk cap pressure")
|
||||
}
|
||||
if st.CapacityPressureEvents == 0 {
|
||||
t.Error("capacity_pressure_events not incremented under disk cap pressure")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiskFS_EvictAndLazyStat(t *testing.T) {
|
||||
t.Parallel()
|
||||
td := t.TempDir()
|
||||
@@ -138,10 +175,21 @@ func TestDiskFS_EvictAndLazyStat(t *testing.T) {
|
||||
w.Write(make([]byte, 120))
|
||||
w.Close()
|
||||
}
|
||||
met := metrics.NewMetrics()
|
||||
d.SetMetrics(met)
|
||||
ev := d.EvictLRU(200)
|
||||
if ev == 0 {
|
||||
t.Log("no evict (size calc async or snapshot tolerance?)")
|
||||
}
|
||||
if ev > 0 {
|
||||
st := met.GetStats()
|
||||
if st.Evictions == 0 {
|
||||
t.Error("evictions counter not incremented after disk EvictLRU freed bytes")
|
||||
}
|
||||
if st.CapacityPressureEvents == 0 {
|
||||
t.Error("capacity_pressure_events not incremented after disk EvictLRU freed bytes")
|
||||
}
|
||||
}
|
||||
// Explicit post-evict consistency checks: for any key no longer visible via Stat, its on-disk
|
||||
// file must be absent (verifies coordinated unlink + no resurrection via lazy discovery).
|
||||
// Keys still present after this small evict are allowed (accounting tolerance in raw DiskFS).
|
||||
@@ -371,7 +419,8 @@ func testKey(i int) string {
|
||||
// artifacts for victims are immediately gone (no resurrection via lazy discovery in Stat/Open),
|
||||
// and that recreating the same key produces independent content that is not subject to any
|
||||
// stale eviction unlinks. This exercises the coordinated WLock remove path for DiskFS.
|
||||
// Uses tolerant checks suitable for raw DiskFS lazy discovery + bg size.
|
||||
// Create does not wait on initDone, so this also covers insertBatch racing with eviction:
|
||||
// gone files must not be re-indexed (Stat present / disk missing).
|
||||
func TestDiskFS_EvictDiskVisibilityAndRecreateSafety(t *testing.T) {
|
||||
t.Parallel()
|
||||
td := t.TempDir()
|
||||
@@ -400,47 +449,21 @@ func TestDiskFS_EvictDiskVisibilityAndRecreateSafety(t *testing.T) {
|
||||
_ = d.EvictBySize(1024*1024, true)
|
||||
}
|
||||
|
||||
// Consistency check: never have a key absent from Stat but with a file on disk (would indicate
|
||||
// either resurrection risk or orphan). If Stat succeeds, file should exist.
|
||||
// A few retries tolerate the documented lazy discovery + eviction coordination windows under
|
||||
// artificial "force massive eviction then immediate audit" load (especially visible under -race).
|
||||
for attempt := 0; attempt < 3; attempt++ {
|
||||
bad := false
|
||||
for _, k := range created {
|
||||
p := d.pathForKey(k)
|
||||
_, statErr := d.Stat(k)
|
||||
_, diskErr := os.Stat(p)
|
||||
if statErr != nil {
|
||||
if !os.IsNotExist(diskErr) {
|
||||
bad = true
|
||||
}
|
||||
} else {
|
||||
if diskErr != nil {
|
||||
bad = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if !bad {
|
||||
break
|
||||
}
|
||||
if attempt < 2 {
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
} else {
|
||||
// On final attempt, report the last observed state for the keys
|
||||
for _, k := range created {
|
||||
p := d.pathForKey(k)
|
||||
_, statErr := d.Stat(k)
|
||||
_, diskErr := os.Stat(p)
|
||||
if statErr != nil {
|
||||
if !os.IsNotExist(diskErr) {
|
||||
t.Errorf("key %s absent via Stat but file lingers on disk at %s (resurrection risk)", k, p)
|
||||
}
|
||||
} else {
|
||||
if diskErr != nil {
|
||||
t.Errorf("key %s present via Stat but missing on disk: %v", k, diskErr)
|
||||
}
|
||||
}
|
||||
// Drain bg population so insertBatch cannot still be in flight when we audit.
|
||||
_ = d.Size()
|
||||
|
||||
// Consistency: Stat success iff the file exists on disk. insertBatch must not resurrect
|
||||
// keys whose backing files were already evicted.
|
||||
for _, k := range created {
|
||||
p := d.pathForKey(k)
|
||||
_, statErr := d.Stat(k)
|
||||
_, diskErr := os.Stat(p)
|
||||
if statErr != nil {
|
||||
if !os.IsNotExist(diskErr) {
|
||||
t.Errorf("key %s absent via Stat but file lingers on disk at %s (resurrection risk)", k, p)
|
||||
}
|
||||
} else if diskErr != nil {
|
||||
t.Errorf("key %s present via Stat but missing on disk: %v", k, diskErr)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -464,6 +487,64 @@ func TestDiskFS_EvictDiskVisibilityAndRecreateSafety(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestDiskFS_InsertBatchSkipsGoneFiles is the fail-closed contract for bg/lazy index
|
||||
// insert: a discoveredFile whose path was removed (evicted) must not be re-inserted
|
||||
// into d.info. That resurrection is what made Stat succeed while os.Stat failed.
|
||||
func TestDiskFS_InsertBatchSkipsGoneFiles(t *testing.T) {
|
||||
t.Parallel()
|
||||
td := t.TempDir()
|
||||
d, err := New(td, 10*1024*1024, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = d.Size() // finish constructor scan so it cannot also index these keys
|
||||
|
||||
liveKey := "live"
|
||||
goneKey := "gone"
|
||||
writeKey := func(key, body string) os.FileInfo {
|
||||
t.Helper()
|
||||
p := d.pathForKey(key)
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(p, []byte(body), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st, err := os.Stat(p)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return st
|
||||
}
|
||||
liveInfo := writeKey(liveKey, "still-here")
|
||||
goneInfo := writeKey(goneKey, "about-to-vanish")
|
||||
if err := os.Remove(d.pathForKey(goneKey)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
d.insertBatch([]discoveredFile{
|
||||
{key: liveKey, size: liveInfo.Size(), osInfo: liveInfo},
|
||||
{key: goneKey, size: goneInfo.Size(), osInfo: goneInfo},
|
||||
})
|
||||
|
||||
d.mu.RLock()
|
||||
_, liveExists := d.info[liveKey]
|
||||
_, goneExists := d.info[goneKey]
|
||||
d.mu.RUnlock()
|
||||
if !liveExists {
|
||||
t.Errorf("insertBatch skipped live key %s", liveKey)
|
||||
}
|
||||
if goneExists {
|
||||
t.Errorf("insertBatch resurrected gone key %s", goneKey)
|
||||
}
|
||||
if _, err := d.Stat(goneKey); err == nil {
|
||||
t.Errorf("Stat succeeded for gone key %s", goneKey)
|
||||
}
|
||||
if _, err := os.Stat(d.pathForKey(liveKey)); err != nil {
|
||||
t.Errorf("live key %s missing on disk: %v", liveKey, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDiskFS_EvictBoundedLargeN exercises the maxEvictBatch early-break logic (Idea #2)
|
||||
// under a map size >> batch limit. Forces repeated eviction rounds via GC-style pressure
|
||||
// and asserts progress + consistency (no resurrection/orphans). Covers bounded collection
|
||||
@@ -581,3 +662,57 @@ func TestDiskFS_NewMkdirError(t *testing.T) {
|
||||
t.Errorf("expected mkdir failure error for file-as-dir, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDiskFS_InitHoldBlocksOnlyRegisteredRoot covers the per-root init hold:
|
||||
// Size() stays blocked while the hold is open, and a DiskFS on a different root
|
||||
// does not wait on that hold.
|
||||
func TestDiskFS_InitHoldBlocksOnlyRegisteredRoot(t *testing.T) {
|
||||
td := t.TempDir()
|
||||
hold := make(chan struct{})
|
||||
var holdOnce sync.Once
|
||||
closeHold := func() { holdOnce.Do(func() { close(hold) }) }
|
||||
RegisterInitHold(td, hold)
|
||||
t.Cleanup(func() {
|
||||
closeHold()
|
||||
ClearInitHold(td)
|
||||
})
|
||||
|
||||
d, err := New(td, 10*1024*1024, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
blocked := make(chan struct{})
|
||||
go func() {
|
||||
_ = d.Size()
|
||||
close(blocked)
|
||||
}()
|
||||
select {
|
||||
case <-blocked:
|
||||
t.Fatal("Size returned while init hold still open")
|
||||
case <-time.After(50 * time.Millisecond):
|
||||
}
|
||||
|
||||
td2 := t.TempDir()
|
||||
d2, err := New(td2, 10*1024*1024, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
other := make(chan struct{})
|
||||
go func() {
|
||||
_ = d2.Size()
|
||||
close(other)
|
||||
}()
|
||||
select {
|
||||
case <-other:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("unrelated DiskFS Size hung; init hold leaked across roots")
|
||||
}
|
||||
|
||||
closeHold()
|
||||
select {
|
||||
case <-blocked:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("Size did not return after init hold released")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
//go:build !windows
|
||||
|
||||
package disk
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// isNoSpaceError reports whether err is ENOSPC (or wraps it).
|
||||
func isNoSpaceError(err error) bool {
|
||||
return err != nil && errors.Is(err, unix.ENOSPC)
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
//go:build !windows
|
||||
|
||||
package disk
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
|
||||
"s1d3sw1ped/steamcache2/steamcache/metrics"
|
||||
)
|
||||
|
||||
func TestIsNoSpaceError(t *testing.T) {
|
||||
t.Parallel()
|
||||
if isNoSpaceError(nil) {
|
||||
t.Error("nil must not be ENOSPC")
|
||||
}
|
||||
if isNoSpaceError(io.EOF) {
|
||||
t.Error("EOF must not be ENOSPC")
|
||||
}
|
||||
if !isNoSpaceError(unix.ENOSPC) {
|
||||
t.Error("unix.ENOSPC should match")
|
||||
}
|
||||
wrapped := &os.PathError{Op: "write", Path: "x", Err: unix.ENOSPC}
|
||||
if !isNoSpaceError(wrapped) {
|
||||
t.Error("PathError wrapping ENOSPC should match")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiskFS_ENOSPCCapacityPressure(t *testing.T) {
|
||||
t.Parallel()
|
||||
d, err := New(t.TempDir(), 1024, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
met := metrics.NewMetrics()
|
||||
d.SetMetrics(met)
|
||||
|
||||
d.recordIfNoSpace(io.EOF)
|
||||
if got := met.GetStats().CapacityPressureEvents; got != 0 {
|
||||
t.Fatalf("non-ENOSPC counted: %d", got)
|
||||
}
|
||||
|
||||
d.recordIfNoSpace(unix.ENOSPC)
|
||||
if got := met.GetStats().CapacityPressureEvents; got != 1 {
|
||||
t.Fatalf("unix.ENOSPC: CapacityPressureEvents=%d, want 1", got)
|
||||
}
|
||||
if got := met.GetStats().Evictions; got != 0 {
|
||||
t.Fatalf("ENOSPC must not increment evictions, got %d", got)
|
||||
}
|
||||
|
||||
d.recordIfNoSpace(&os.PathError{Op: "write", Path: "p", Err: unix.ENOSPC})
|
||||
if got := met.GetStats().CapacityPressureEvents; got != 2 {
|
||||
t.Fatalf("wrapped ENOSPC: CapacityPressureEvents=%d, want 2", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
//go:build windows
|
||||
|
||||
package disk
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// isNoSpaceError reports whether err is a Windows disk-full equivalent of ENOSPC.
|
||||
func isNoSpaceError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
return errors.Is(err, windows.ERROR_DISK_FULL) || errors.Is(err, windows.ERROR_HANDLE_DISK_FULL)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
//go:build windows
|
||||
|
||||
package disk
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
|
||||
"s1d3sw1ped/steamcache2/steamcache/metrics"
|
||||
)
|
||||
|
||||
func TestIsNoSpaceError(t *testing.T) {
|
||||
t.Parallel()
|
||||
if isNoSpaceError(nil) {
|
||||
t.Error("nil must not be disk-full")
|
||||
}
|
||||
if isNoSpaceError(io.EOF) {
|
||||
t.Error("EOF must not be disk-full")
|
||||
}
|
||||
if !isNoSpaceError(windows.ERROR_DISK_FULL) {
|
||||
t.Error("ERROR_DISK_FULL should match")
|
||||
}
|
||||
if !isNoSpaceError(windows.ERROR_HANDLE_DISK_FULL) {
|
||||
t.Error("ERROR_HANDLE_DISK_FULL should match")
|
||||
}
|
||||
wrapped := &os.PathError{Op: "write", Path: "x", Err: windows.ERROR_DISK_FULL}
|
||||
if !isNoSpaceError(wrapped) {
|
||||
t.Error("PathError wrapping ERROR_DISK_FULL should match")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiskFS_ENOSPCCapacityPressure(t *testing.T) {
|
||||
t.Parallel()
|
||||
d, err := New(t.TempDir(), 1024, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
met := metrics.NewMetrics()
|
||||
d.SetMetrics(met)
|
||||
|
||||
d.recordIfNoSpace(io.EOF)
|
||||
if got := met.GetStats().CapacityPressureEvents; got != 0 {
|
||||
t.Fatalf("non-ENOSPC counted: %d", got)
|
||||
}
|
||||
|
||||
d.recordIfNoSpace(windows.ERROR_DISK_FULL)
|
||||
if got := met.GetStats().CapacityPressureEvents; got != 1 {
|
||||
t.Fatalf("ERROR_DISK_FULL: CapacityPressureEvents=%d, want 1", got)
|
||||
}
|
||||
if got := met.GetStats().Evictions; got != 0 {
|
||||
t.Fatalf("disk-full must not increment evictions, got %d", got)
|
||||
}
|
||||
}
|
||||
+8
-17
@@ -289,7 +289,8 @@ func (m *MemoryFS) Delete(key string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stat returns file information
|
||||
// Stat returns a snapshot of file information. The returned *FileInfo is not
|
||||
// the live cache entry; Close may update Size on the in-map object under m.mu.
|
||||
func (m *MemoryFS) Stat(key string) (*types.FileInfo, error) {
|
||||
if key == "" {
|
||||
return nil, vfserror.ErrInvalidKey
|
||||
@@ -310,7 +311,7 @@ func (m *MemoryFS) Stat(key string) (*types.FileInfo, error) {
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
if fi, ok := m.info[key]; ok {
|
||||
return fi, nil
|
||||
return fi.Clone(), nil
|
||||
}
|
||||
|
||||
return nil, vfserror.ErrNotFound
|
||||
@@ -357,9 +358,7 @@ func (m *MemoryFS) EvictLRU(bytesNeeded uint) uint {
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
if m.metrics != nil && evicted > 0 {
|
||||
m.metrics.IncrementEvictions()
|
||||
}
|
||||
metrics.NoteSoftEviction(m.metrics, "memory", evicted)
|
||||
return evicted
|
||||
}
|
||||
|
||||
@@ -412,9 +411,7 @@ func (m *MemoryFS) EvictBySize(bytesNeeded uint, ascending bool) uint {
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
if m.metrics != nil && evicted > 0 {
|
||||
m.metrics.IncrementEvictions()
|
||||
}
|
||||
metrics.NoteSoftEviction(m.metrics, "memory", evicted)
|
||||
return evicted
|
||||
}
|
||||
|
||||
@@ -463,9 +460,7 @@ func (m *MemoryFS) EvictFIFO(bytesNeeded uint) uint {
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
if m.metrics != nil && evicted > 0 {
|
||||
m.metrics.IncrementEvictions()
|
||||
}
|
||||
metrics.NoteSoftEviction(m.metrics, "memory", evicted)
|
||||
return evicted
|
||||
}
|
||||
|
||||
@@ -519,9 +514,7 @@ func (m *MemoryFS) EvictLFU(bytesNeeded uint) uint {
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
if m.metrics != nil && evicted > 0 {
|
||||
m.metrics.IncrementEvictions()
|
||||
}
|
||||
metrics.NoteSoftEviction(m.metrics, "memory", evicted)
|
||||
return evicted
|
||||
}
|
||||
|
||||
@@ -577,8 +570,6 @@ func (m *MemoryFS) EvictHybrid(bytesNeeded uint) uint {
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
if m.metrics != nil && evicted > 0 {
|
||||
m.metrics.IncrementEvictions()
|
||||
}
|
||||
metrics.NoteSoftEviction(m.metrics, "memory", evicted)
|
||||
return evicted
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ import (
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"s1d3sw1ped/steamcache2/steamcache/metrics"
|
||||
)
|
||||
|
||||
func TestMemoryFS_Basic(t *testing.T) {
|
||||
@@ -60,6 +62,8 @@ func TestMemoryFS_EvictUnderPressure(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
met := metrics.NewMetrics()
|
||||
m.SetMetrics(met)
|
||||
// create 3x200 = 600 >500, should trigger internal? but direct evict call
|
||||
for i := 0; i < 3; i++ {
|
||||
w, _ := m.Create("f"+string(rune('0'+i)), 200)
|
||||
@@ -71,6 +75,13 @@ func TestMemoryFS_EvictUnderPressure(t *testing.T) {
|
||||
if evicted == 0 || m.Size() > 500 {
|
||||
t.Errorf("evict failed: evicted=%d size=%d", evicted, m.Size())
|
||||
}
|
||||
st := met.GetStats()
|
||||
if st.Evictions == 0 {
|
||||
t.Error("evictions counter not incremented under memory cap pressure")
|
||||
}
|
||||
if st.CapacityPressureEvents == 0 {
|
||||
t.Error("capacity_pressure_events not incremented under memory cap pressure")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemoryFS_SizeNeverExceedsAfterEvict(t *testing.T) {
|
||||
@@ -346,6 +357,45 @@ func TestMemoryFS_ConcurrentCloseAndEvict_RaceFree(t *testing.T) {
|
||||
_ = m.LRU.Len()
|
||||
}
|
||||
|
||||
func TestMemoryFS_StatReturnsSnapshot(t *testing.T) {
|
||||
t.Parallel()
|
||||
m, err := New(1024)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w, err := m.Create("k", 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := w.Write([]byte("hello")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := w.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
fi, err := m.Stat("k")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if fi.Size != 5 {
|
||||
t.Fatalf("size %d want 5", fi.Size)
|
||||
}
|
||||
fi.Size = 999
|
||||
fi.AccessCount = 0
|
||||
|
||||
fi2, err := m.Stat("k")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if fi2.Size != 5 {
|
||||
t.Errorf("Stat returned live FileInfo; store size became %d", fi2.Size)
|
||||
}
|
||||
if fi2.AccessCount == 0 {
|
||||
t.Error("Stat returned live FileInfo; AccessCount mutation leaked")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemoryFS_EvictVariantsAndErrors(t *testing.T) {
|
||||
t.Parallel()
|
||||
m, err := New(800)
|
||||
|
||||
@@ -27,6 +27,17 @@ func NewFileInfo(key string, size int64) *FileInfo {
|
||||
}
|
||||
}
|
||||
|
||||
// Clone returns a snapshot copy of fi. Stat returns Clone() so callers can
|
||||
// read Size and other fields without racing Close/Open mutations of the
|
||||
// in-map FileInfo.
|
||||
func (fi *FileInfo) Clone() *FileInfo {
|
||||
if fi == nil {
|
||||
return nil
|
||||
}
|
||||
cp := *fi
|
||||
return &cp
|
||||
}
|
||||
|
||||
// NewFileInfoFromOS creates a FileInfo from os.FileInfo
|
||||
func NewFileInfoFromOS(info os.FileInfo, key string) *FileInfo {
|
||||
return &FileInfo{
|
||||
|
||||
@@ -16,6 +16,34 @@ func TestNewFileInfo(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileInfoClone(t *testing.T) {
|
||||
t.Parallel()
|
||||
fi := NewFileInfo("k", 42)
|
||||
fi.AccessCount = 7
|
||||
cp := fi.Clone()
|
||||
if cp == fi {
|
||||
t.Fatal("Clone returned the same pointer")
|
||||
}
|
||||
if cp.Key != fi.Key || cp.Size != fi.Size || cp.AccessCount != fi.AccessCount {
|
||||
t.Errorf("Clone mismatch: %+v vs %+v", cp, fi)
|
||||
}
|
||||
if !cp.ATime.Equal(fi.ATime) || !cp.CTime.Equal(fi.CTime) {
|
||||
t.Error("Clone timestamps mismatch")
|
||||
}
|
||||
cp.Size = 99
|
||||
cp.AccessCount = 1
|
||||
if fi.Size != 42 || fi.AccessCount != 7 {
|
||||
t.Error("mutating Clone affected original")
|
||||
}
|
||||
if NewFileInfo("x", 1).Clone() == nil {
|
||||
t.Error("Clone of non-nil was nil")
|
||||
}
|
||||
var none *FileInfo
|
||||
if none.Clone() != nil {
|
||||
t.Error("Clone of nil was non-nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateAccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
fi := NewFileInfo("k", 1)
|
||||
|
||||
Reference in New Issue
Block a user