Compare commits

...

42 Commits

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

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

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

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

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

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

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

Emit Prometheus text 0.0.4 (# HELP, # TYPE, counter|gauge) with stable
metric names, and set Content-Type to text/plain; version=0.0.4; charset=utf-8.
2026-09-08 19:53:58 +00:00
linus 0db9943436 ops: Signal disk-full and eviction capacity pressure (#46)
CI / vulncheck (push) Successful in 14s
CI / check-and-test (push) Successful in 43s
Release Tag / release (push) Successful in 14s
capacity_pressure_events + ENOSPC/eviction logs. Closes #36.
2026-09-08 13:20:22 -05:00
ash e3b2b8de1e test: Hold disk init in DiskOnlyDelayedAttach too
CI / vulncheck (pull_request) Successful in 15s
CI / check-and-test (pull_request) Successful in 42s
Empty-dir attach can finish before the pending DiskTierReady
assertion under CI load, same race MixedPendingReady already
fixed with RegisterInitHold. Drop t.Parallel and hold the
barrier through the pending checks so disk-only stays green.

Link: #36
2026-09-08 18:16:18 +00:00
ash fe04fb9fbb vfs/disk: Harden pending-window test against empty-dir race
Empty-dir disk init closes initDone almost immediately, so
TestDiskTierSignalMixedPendingReady can observe DiskTierReady=0 then
a heartbeat that already saw ready. Register a per-root init hold so
the test can keep the pending window open without slowing production.

Link: #36
2026-09-08 18:16:18 +00:00
ash de43a71929 ops: Signal disk-full and eviction capacity pressure
When the disk (or memory) tier is at cap or the volume returns ENOSPC,
ops currently look like random misses with no clear "we are dropping
data." Count those events as capacity_pressure_events on /metrics and
log tier plus reason so operators can tell capacity pressure from a
cold cache, without changing the existing evictions counter.

Link: #36
2026-09-08 18:15:51 +00:00
eva a0e929c525 Merge pull request 'cache: Short TTL negative cache for 404/410 depot objects' (#50) from cache/negative-ttl-404 into main
CI / vulncheck (push) Successful in 14s
CI / check-and-test (push) Failing after 40s
2026-09-08 11:49:07 -05:00
pike 036ea1ea7f cache: Short TTL negative cache for 404/410 depot objects
CI / vulncheck (pull_request) Successful in 14s
CI / check-and-test (pull_request) Successful in 41s
Stop re-fetching gone depot objects on every miss: store 404/410 in the
existing VFS cache under the same key with a short TTL (default 5m).
2026-09-08 16:40:03 +00:00
linus 8eb31143e5 cache: Key by depot path across CDN host aliases (#48)
CI / vulncheck (push) Successful in 14s
CI / check-and-test (push) Successful in 41s
Release Tag / release (push) Successful in 15s
Cache key from Path only; Host aliases share one entry. Closes #39.
2026-09-07 15:18:26 -05:00
moss 337741e06e cache: Key by depot path across CDN host aliases
CI / vulncheck (pull_request) Successful in 14s
CI / check-and-test (pull_request) Successful in 41s
2026-09-07 20:14:30 +00:00
linus 2c8ec276e7 ops: Widen validate-check for upstream/write/rate failures (#47)
CI / vulncheck (push) Successful in 15s
CI / check-and-test (push) Successful in 41s
Highlight upstream_errors/cache_write_failures/rate_limited. Closes #34.
2026-09-07 14:53:37 -05:00
pike 144a68dcd7 ops: Widen validate-check for upstream/write/rate failures
CI / vulncheck (pull_request) Successful in 15s
CI / check-and-test (pull_request) Successful in 41s
Surface upstream_errors, cache_write_failures, and rate_limited in
make validate-check highlights and Quick check docs so a hot failure
path cannot look fine when hits exist.

Closes #34
2026-09-07 19:51:50 +00:00
linus 8cebc1f96c ops: Signal disk-tier attach pending vs ready (#45)
CI / vulncheck (push) Successful in 15s
CI / check-and-test (push) Successful in 41s
Release Tag / release (push) Successful in 15s
disk_tier_ready + X-SteamCache-Disk-Tier. Closes #33.
2026-09-07 12:02:10 -05:00
pike 12ea3ee4f6 ops: Signal disk-tier attach pending vs ready
CI / vulncheck (pull_request) Successful in 14s
CI / check-and-test (pull_request) Successful in 40s
Large disk caches can look memory-only/broken during async DiskFS attach.
Expose disk_tier_ready in /metrics, pending/ready logs for disk-only and
mixed modes, and X-SteamCache-Disk-Tier on /lancache-heartbeat. GetMetrics
skips blocking disk.Size() while attach is pending so /metrics stays usable.

Closes #33
2026-09-07 16:58:13 +00:00
linus a500f51f17 cache: Serve Range GET from disk when present (#44)
CI / vulncheck (push) Successful in 14s
CI / check-and-test (push) Successful in 41s
Range HIT/MISS as 206; range_cache/range_upstream metrics. Closes #38.
2026-09-07 11:37:06 -05:00
moss ac2d36f1ad cache: Serve Range GET from disk when present
CI / vulncheck (pull_request) Successful in 15s
CI / check-and-test (pull_request) Successful in 41s
Steam clients lean on Range requests. Hit/miss looked fine while downloads
still felt cold when a Range miss returned the full upstream body as 200.

Add range_cache / range_upstream metrics. HIT path already sliced 206 from
the cached full object; count range_cache there. On MISS, keep stripping
Range for the upstream fetch (full object still cached) but serve the
client's requested slice as 206 and count range_upstream.

Tests in steamcache/range_test.go cover Range HIT (local, no upstream),
Range MISS (206 + full object cached), and /metrics emission.
2026-09-07 16:32:36 +00:00
linus c43bfba568 Merge pull request 'docs: Real DNS + empty upstream Quick Start' (#43) from docs/quick-start-real-dns into main
docs: Real DNS + empty upstream Quick Start

Fixes #32
2026-09-07 10:00:58 -05:00
eva 3411defd51 docs: Real DNS + empty upstream Quick Start
CI / vulncheck (pull_request) Successful in 15s
CI / check-and-test (pull_request) Successful in 40s
2026-09-07 14:58:06 +00:00
linus 71d5106777 docs: Add README operator cache check (#30)
CI / vulncheck (push) Successful in 14s
CI / check-and-test (push) Successful in 41s
Release Tag / release (push) Successful in 16s
README Quick check + make validate-check using existing /metrics and heartbeat. Closes #29.
2026-09-03 00:19:53 -05:00
pike 8b1b229539 docs: Deduplicate validate-check helper
CI / vulncheck (pull_request) Successful in 14s
CI / check-and-test (pull_request) Successful in 41s
Keep a single Makefile validate-check target that prints the full
/metrics dump, highlights hit/miss fields, and curls /lancache-heartbeat.
Drop the duplicate README troubleshooting item and align the validation
chapter plus validate-config.yaml comments with that behavior.

Link: #29
2026-09-03 05:18:19 +00:00
pike ff1ab31327 docs: Add README operator cache check
CI / vulncheck (pull_request) Successful in 14s
CI / check-and-test (pull_request) Successful in 41s
The README buried hit/miss verification under the SteamPrefill validation
chapter, and advertised make validate-check without a Makefile target.
Operators need a short start → traffic → metrics path against the existing
/metrics and /lancache-heartbeat endpoints.

Link: #29
2026-09-03 05:16:14 +00:00
s1d3sw1ped_bot 85e14bc8af README: Replace --threads with real cobra concurrency flags
Release Tag / release (push) Successful in 17s
Docs sync: align README with current install/CLI behavior.

Co-authored-by: s1d3sw1ped_bot <s1d3sw1ped+giteabot@gmail.com>
Co-committed-by: s1d3sw1ped_bot <s1d3sw1ped+giteabot@gmail.com>
2026-09-02 10:43:52 -05:00
s1d3sw1ped_bot 3f5175b482 .gitea/workflows: Prefer job GITEA_TOKEN
CI / vulncheck (push) Successful in 15s
CI / check-and-test (push) Successful in 41s
Release Tag still used secrets.RELEASE_TOKEN. Prefer the job built-in token with contents: write, same path as vulture/teleport.

Link: #26
Co-authored-by: s1d3sw1ped_bot <s1d3sw1ped+giteabot@gmail.com>
Co-committed-by: s1d3sw1ped_bot <s1d3sw1ped+giteabot@gmail.com>
2026-09-02 10:08:00 -05:00
s1d3sw1ped_bot 04d1c6c368 Merge pull request 'Promote develop: vfs/cache promoteToFast race fix' (#24) from develop into main
CI / vulncheck (push) Successful in 15s
CI / check-and-test (push) Successful in 41s
Release Tag / release (push) Successful in 30s
Promote vfs/cache Stat snapshot + promoteToFast size fix.
2026-09-01 15:36:49 -05:00
s1d3sw1ped_bot 523a9a4782 Merge pull request 'vfs/cache: Fix promoteToFast race with WriteCloser Close' (#23) from vfs/cache-promote-race into develop
CI / vulncheck (pull_request) Successful in 14s
CI / check-and-test (pull_request) Successful in 41s
Stat returns FileInfo snapshots; promoteToFast sizes from len(content).

#21
2026-09-01 15:35:20 -05:00
s1d3sw1ped_bot 8e09c89e24 vfs/cache: Fix promoteToFast race with WriteCloser Close
CI / vulncheck (pull_request) Successful in 14s
CI / check-and-test (pull_request) Successful in 40s
promoteToFast read FileInfo.Size from Stat after the VFS lock was
released, while WriteCloser.Close updated Size on the same live
object. Concurrent Create/Open on overlapping keys tripped the race
detector.

Stat now returns a FileInfo snapshot, and promotion uses the
ReadAll length for the fast-tier Create size. Promotion stays
best-effort.

#21
2026-09-01 20:30:40 +00:00
s1d3sw1ped_bot a2ac13d317 vfs/disk: Fix EvictDiskVisibilityAndRecreateSafety flake
CI / vulncheck (push) Successful in 13s
CI / check-and-test (push) Failing after 40s
2026-09-01 15:23:10 -05:00
s1d3sw1ped_bot 5d006ac44f vfs/disk: Fix EvictDiskVisibilityAndRecreateSafety flake
CI / vulncheck (pull_request) Successful in 14s
CI / check-and-test (pull_request) Successful in 41s
2026-09-01 15:21:05 -05:00
s1d3sw1ped_bot acd006d4a2 ci: Skip test workflow on markdown-only pushes
CI / vulncheck (pull_request) Successful in 14s
CI / check-and-test (pull_request) Successful in 40s
Docs-only pushes to main (**.md / CONTRIBUTING.md) do not need the
full check-and-test job. Pull requests are unchanged.
2026-09-01 20:19:19 +00:00
s1d3sw1ped_bot 30a695458e vfs/disk: Fix EvictDiskVisibilityAndRecreateSafety flake
New() launches background calculateSizeAndPopulateIndex which scans
disk and calls insertBatch. Create does not wait on initDone, so a
file can be written and indexed, discovered by the scan, then removed
from d.info and disk by EvictLRU/EvictBySize. insertBatch then
re-inserted the stale discoveredFile without checking the path still
existed, so Stat succeeded from the index while os.Stat failed.

Re-stat under the lock and skip gone files so evicted keys are not
resurrected.

Fixes #18.
2026-09-01 20:19:14 +00:00
s1d3sw1ped_bot e8bcf0ddbd docs: Add CONTRIBUTING.md
CI / vulncheck (push) Successful in 14s
CI / check-and-test (push) Successful in 41s
Add short contribution guide on main from develop tip content.
2026-09-01 14:11:02 -05:00
s1d3sw1ped_bot f497e71ef0 docs: Add CONTRIBUTING.md
CI / vulncheck (pull_request) Successful in 16s
CI / check-and-test (pull_request) Successful in 43s
Copy the develop tip contribution guide onto main so the default
branch carries the same short CONTRIBUTING.md.
2026-09-01 19:10:15 +00:00
s1d3sw1ped_bot 0198e8990b docs: Add CONTRIBUTING.md
CI / vulncheck (pull_request) Successful in 31s
CI / check-and-test (pull_request) Failing after 57s
Contributors need a short guide for develop-targeted PRs, commit
subject form, and Gitea issue-closing rules.
2026-09-01 13:37:56 -05:00
s1d3sw1ped_bot 2a2cd8d393 Bump Go to 1.27.0 (#14)
CI / vulncheck (push) Successful in 8s
CI / check-and-test (push) Successful in 35s
2026-08-31 20:04:49 -05:00
s1d3sw1ped_bot 81b3a7df53 Restrict Host-based fetches to Steam CDN names (#13)
CI / vulncheck (push) Successful in 7s
CI / check-and-test (push) Successful in 27s
Allowlist steamcontent/steampowered/steamstatic Hosts; do not follow upstream redirects.
2026-08-31 18:58:53 -05:00
s1d3sw1ped_bot 8e8e877533 Restrict Host-based origin fetches to Steam CDN names.
CI / vulncheck (pull_request) Successful in 7s
CI / check-and-test (pull_request) Successful in 28s
When upstream is empty the cache used the client Host as the fetch URL, so any LAN client with a spoofed Steam User-Agent could proxy to literal IPs or arbitrary names. Reject those hosts, stop following upstream redirects, and keep path-only cache keys so real Steam CDNs still share entries.
2026-08-31 23:56:03 +00:00
34 changed files with 3302 additions and 279 deletions
+5 -1
View File
@@ -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
+6 -3
View File
@@ -4,6 +4,9 @@ on:
push:
branches:
- main
paths-ignore:
- '**.md'
- 'CONTRIBUTING.md'
jobs:
check-and-test:
@@ -12,14 +15,14 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.26.7'
go-version-file: 'go.mod'
- run: go mod tidy
- run: go build ./...
- run: go vet ./...
- name: golangci-lint
uses: golangci/golangci-lint-action@v8
with:
version: v2.12
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
@@ -30,6 +33,6 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.27.0'
go-version-file: 'go.mod'
- run: go install golang.org/x/vuln/cmd/govulncheck@latest
- run: govulncheck ./...
+40
View File
@@ -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 ≤ ~7075 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.
+45 -1
View File
@@ -62,6 +62,49 @@ validate run-validation: build clean-disk ## Start steamcache2 on :80 with small
fi; \
exec "$$BINARY" --config docs/examples/validate-config.yaml --log-level info
validate-check: ## Curl local /metrics (full dump + hit/miss + upstream/write/rate fields), /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 "=== 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)..."
@for port in 80 8040 8080; do \
@@ -109,6 +152,7 @@ help: ## Show this help message
@echo " clean-disk Remove disk cache"
@echo " bench Run low-level VFS microbenchmarks"
@echo " validate / run-validation Start server on :80 (builds, auto-setcaps fresh binary, then runs as normal user, cleans disk cache first)"
@echo " validate-check Curl local /metrics (full dump + hit/miss + upstream/write/rate fields), /lancache-heartbeat, and non-Steam Host reject probe (empty upstream; default :80)"
@echo " setcap Explicitly set cap on current build (for port 80 use outside validate)"
@echo " validate-kill Kill leftover steamcache2 processes (safer)"
@echo " prefill Download latest SteamPrefill into bin/steam-prefill/SteamPrefill (gitignored)"
@echo " prefill Download latest SteamPrefill into bin/steam-prefill/SteamPrefill (gitignored)"
+117 -20
View File
@@ -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.
@@ -96,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
@@ -110,14 +179,17 @@ 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
@@ -128,7 +200,7 @@ The recommended validation config is at [docs/examples/validate-config.yaml](doc
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
@@ -160,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
@@ -179,6 +256,11 @@ listen_address: :80
max_object_size: "0" # 0=unlimited; set e.g. "256MB" for response size DoS protection
trusted_proxies: [] # empty = safe (ignore XFF for rate limit); set CIDRs for trusted proxies
# Table-tier uplink bandwidth shaping (bytes/sec). Empty/0 = disabled (unlimited).
# Distinct from max_requests_per_client (concurrency). See "Table-tier uplink fair-share".
uplink_bandwidth: "" # e.g. "10MB" = 10e6 bytes/sec shared fairly across active clients
max_bytes_per_client_per_sec: 0 # optional absolute per-client cap; 0 = no absolute cap
# Cache configuration
cache:
# Memory cache settings
@@ -197,9 +279,14 @@ 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
@@ -223,9 +310,9 @@ See `config.Validate()` and `steamcache.New` error paths. This ensures the LAN a
- These + the startup validation make steamcache2 safe-by-default for LAN exposure.
#### Migration / Breaking Changes
- `New()` public signature gained 2 required trailing params (`maxObjectSize`, `trustedProxies`). Direct callers (rare; most use config or NewWithOptions) must update.
- `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).
@@ -234,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
@@ -311,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
@@ -319,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
@@ -371,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
View File
@@ -20,8 +20,10 @@ var (
logLevel string
logFormat string
maxConcurrentRequests int64
maxRequestsPerClient int64
maxConcurrentRequests int64
maxRequestsPerClient int64
uplinkBandwidth string
maxBytesPerClientPerSec int64
)
var rootCmd = &cobra.Command{
@@ -107,6 +109,12 @@ var rootCmd = &cobra.Command{
if maxRequestsPerClient > 0 {
finalMaxRequestsPerClient = maxRequestsPerClient
}
if uplinkBandwidth != "" {
cfg.UplinkBandwidth = uplinkBandwidth
}
if maxBytesPerClientPerSec > 0 {
cfg.MaxBytesPerClientPerSec = maxBytesPerClientPerSec
}
// Validate after loading and applying CLI overrides (fail fast, do not create default on validate error)
if err := cfg.Validate(); err != nil {
@@ -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)")
}
+34
View File
@@ -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 {
+106
View File
@@ -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)
}
+5
View File
@@ -27,7 +27,11 @@
# 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)
+2 -1
View File
@@ -1,6 +1,6 @@
module s1d3sw1ped/steamcache2
go 1.26.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
)
+2
View File
@@ -27,6 +27,8 @@ golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE=
golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+158
View File
@@ -0,0 +1,158 @@
// steamcache/bandwidth.go
// Per-client fair-share / absolute bandwidth shaping for table-tier uplink.
// Distinct from max_requests_per_client concurrency (semaphores in ratelimit.go).
package steamcache
import (
"context"
"net/http"
"sync"
"golang.org/x/time/rate"
)
const bandwidthWriteChunk = 32 * 1024
// clientBandwidthLimiter fair-shares uplinkBytesPerSec among active clients and/or
// applies an absolute per-client bytes/sec cap. Both 0 disables shaping.
type clientBandwidthLimiter struct {
uplinkBytesPerSec int64
absoluteCap int64
mu sync.Mutex
active map[string]int // refcount of in-flight shaped responses per client IP
limiters map[string]*rate.Limiter
}
func newClientBandwidthLimiter(uplinkBytesPerSec, absoluteCap int64) *clientBandwidthLimiter {
if uplinkBytesPerSec < 0 {
uplinkBytesPerSec = 0
}
if absoluteCap < 0 {
absoluteCap = 0
}
return &clientBandwidthLimiter{
uplinkBytesPerSec: uplinkBytesPerSec,
absoluteCap: absoluteCap,
active: make(map[string]int),
limiters: make(map[string]*rate.Limiter),
}
}
func (b *clientBandwidthLimiter) enabled() bool {
return b != nil && (b.uplinkBytesPerSec > 0 || b.absoluteCap > 0)
}
// acquire registers clientIP as actively downloading and returns its limiter
// (nil if shaping disabled) plus a release func that must be deferred.
func (b *clientBandwidthLimiter) acquire(clientIP string) (*rate.Limiter, func()) {
if !b.enabled() {
return nil, func() {}
}
b.mu.Lock()
b.active[clientIP]++
lim := b.ensureLimiterLocked(clientIP)
b.recomputeRatesLocked()
b.mu.Unlock()
var once sync.Once
release := func() {
once.Do(func() {
b.mu.Lock()
defer b.mu.Unlock()
if n := b.active[clientIP]; n <= 1 {
delete(b.active, clientIP)
} else {
b.active[clientIP] = n - 1
}
b.recomputeRatesLocked()
})
}
return lim, release
}
func (b *clientBandwidthLimiter) ensureLimiterLocked(clientIP string) *rate.Limiter {
if lim, ok := b.limiters[clientIP]; ok {
return lim
}
// Start with a placeholder; recomputeRatesLocked sets the real rate.
lim := rate.NewLimiter(rate.Limit(1), 1)
b.limiters[clientIP] = lim
return lim
}
func (b *clientBandwidthLimiter) recomputeRatesLocked() {
n := len(b.active)
if n == 0 {
return
}
var fair int64
if b.uplinkBytesPerSec > 0 {
fair = b.uplinkBytesPerSec / int64(n)
if fair < 1 {
fair = 1
}
}
for ip := range b.active {
r := fair
if b.absoluteCap > 0 {
if r == 0 || b.absoluteCap < r {
r = b.absoluteCap
}
}
if r < 1 {
r = 1
}
lim := b.ensureLimiterLocked(ip)
burst := int(r)
if burst < bandwidthWriteChunk {
burst = bandwidthWriteChunk
}
// Cap burst to avoid huge memory spikes on huge uplinks.
if burst > 4*bandwidthWriteChunk {
burst = 4 * bandwidthWriteChunk
}
lim.SetLimit(rate.Limit(r))
lim.SetBurst(burst)
}
}
// limitedResponseWriter rate-limits response body Write calls. Headers/WriteHeader
// are unlimited. Implements http.ResponseWriter (+ optional Flusher/Hijacker passthrough
// is intentionally omitted — SteamCache body path only needs Write).
type limitedResponseWriter struct {
http.ResponseWriter
lim *rate.Limiter
ctx context.Context
}
func (w *limitedResponseWriter) Write(p []byte) (int, error) {
if w.lim == nil || len(p) == 0 {
return w.ResponseWriter.Write(p)
}
ctx := w.ctx
if ctx == nil {
ctx = context.Background()
}
total := 0
for total < len(p) {
chunk := p[total:]
if len(chunk) > bandwidthWriteChunk {
chunk = chunk[:bandwidthWriteChunk]
}
if err := w.lim.WaitN(ctx, len(chunk)); err != nil {
return total, err
}
n, err := w.ResponseWriter.Write(chunk)
total += n
if err != nil {
return total, err
}
}
return total, nil
}
// Unwrap exposes the underlying ResponseWriter for http.ResponseController etc.
func (w *limitedResponseWriter) Unwrap() http.ResponseWriter {
return w.ResponseWriter
}
+128
View File
@@ -0,0 +1,128 @@
package steamcache
import (
"bytes"
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
)
func TestBandwidthFairShareRates(t *testing.T) {
b := newClientBandwidthLimiter(1000, 0)
lim1, rel1 := b.acquire("1.1.1.1")
defer rel1()
lim2, rel2 := b.acquire("2.2.2.2")
defer rel2()
if lim1 == nil || lim2 == nil {
t.Fatal("expected limiters")
}
// With 2 active clients, each should get ~500 bytes/sec.
got1 := float64(lim1.Limit())
got2 := float64(lim2.Limit())
if got1 < 400 || got1 > 600 || got2 < 400 || got2 > 600 {
t.Fatalf("fair-share rates = %v,%v want ~500", got1, got2)
}
rel2()
// After release, sole client should get full uplink.
lim1b, rel1b := b.acquire("1.1.1.1")
defer rel1b()
if float64(lim1b.Limit()) < 900 {
t.Fatalf("after release limit=%v want ~1000", lim1b.Limit())
}
}
func TestBandwidthAbsoluteCap(t *testing.T) {
b := newClientBandwidthLimiter(0, 250)
lim, rel := b.acquire("9.9.9.9")
defer rel()
if lim == nil {
t.Fatal("expected limiter")
}
if float64(lim.Limit()) != 250 {
t.Fatalf("limit=%v want 250", lim.Limit())
}
}
func TestBandwidthDisabled(t *testing.T) {
b := newClientBandwidthLimiter(0, 0)
lim, rel := b.acquire("9.9.9.9")
defer rel()
if lim != nil {
t.Fatal("expected nil limiter when disabled")
}
}
func TestLimitedResponseWriterShapes(t *testing.T) {
var buf bytes.Buffer
rec := httptest.NewRecorder()
// Use a custom writer sink via ResponseRecorder is fine; WaitN will delay.
lim := newClientBandwidthLimiter(0, 2000) // 2KB/s
l, rel := lim.acquire("127.0.0.1")
defer rel()
w := &limitedResponseWriter{ResponseWriter: rec, lim: l, ctx: context.Background()}
payload := bytes.Repeat([]byte("x"), 4000)
start := time.Now()
n, err := w.Write(payload)
elapsed := time.Since(start)
if err != nil {
t.Fatal(err)
}
if n != len(payload) {
t.Fatalf("wrote %d want %d", n, len(payload))
}
_ = buf
// 4000 bytes at 2000 B/s should take ~2s (allow slack for CI).
if elapsed < 1500*time.Millisecond {
t.Fatalf("elapsed %v too fast for 2KB/s shaping of 4KB", elapsed)
}
if elapsed > 8*time.Second {
t.Fatalf("elapsed %v unexpectedly slow", elapsed)
}
}
func TestServeHTTPBandwidthCap(t *testing.T) {
body := bytes.Repeat([]byte("a"), 3000)
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Content-Length", "3000")
w.WriteHeader(200)
_, _ = w.Write(body)
}))
t.Cleanup(upstream.Close)
sc, err := NewWithOptions(Options{
Address: "127.0.0.1:0",
MemorySize: "1MB",
DiskSize: "0",
Upstream: upstream.URL,
MemoryGC: "lru",
DiskGC: "lru",
MaxConcurrentRequests: 20,
MaxRequestsPerClient: 10,
MaxObjectSize: "0",
MaxBytesPerClientPerSec: 1500, // 1.5KB/s
})
if err != nil {
t.Fatalf("NewWithOptions: %v", err)
}
t.Cleanup(func() { sc.Shutdown() })
req := httptest.NewRequest(http.MethodGet, "/depot/bw/chunk", nil)
req.Header.Set("User-Agent", "Valve/Steam HTTP Client 1.0")
rr := httptest.NewRecorder()
start := time.Now()
sc.ServeHTTP(rr, req)
elapsed := time.Since(start)
if rr.Code != 200 {
t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String())
}
got := rr.Body.Bytes()
if len(got) != len(body) {
t.Fatalf("body len=%d want %d", len(got), len(body))
}
if elapsed < time.Second {
t.Fatalf("elapsed %v too fast for shaping", elapsed)
}
}
+199
View File
@@ -0,0 +1,199 @@
package steamcache
import (
"bytes"
"net/http"
"net/http/httptest"
"sync"
"sync/atomic"
"testing"
"time"
)
func coalescerWaiterCount(sc *SteamCache, cacheKey string) int32 {
sc.coalescer.mu.Lock()
defer sc.coalescer.mu.Unlock()
cr := sc.coalescer.requests[cacheKey]
if cr == nil {
return 0
}
return cr.waitingCount.Load()
}
func steamCoalesceRequest(path string) *http.Request {
req := httptest.NewRequest(http.MethodGet, path, nil)
req.Header.Set("User-Agent", "Valve/Steam HTTP Client 1.0")
return req
}
func waitForCoalescerJoin(t *testing.T, sc *SteamCache, cacheKey string, n int, release func(), wg *sync.WaitGroup, upstreamCalls *atomic.Int64) {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
var waiters int32
for {
waiters = coalescerWaiterCount(sc, cacheKey)
if waiters >= int32(n) {
return
}
if time.Now().After(deadline) {
release()
wg.Wait()
t.Fatalf("coalescer waiters=%d want %d (upstreamCalls=%d)", waiters, n, upstreamCalls.Load())
}
time.Sleep(1 * time.Millisecond)
}
}
// TestCoalesceIdenticalMissesOneUpstreamGET holds the leader's upstream GET
// open until every concurrent client has joined the in-flight coalescer.
// Without that hold, later requests can become sequential HITs after the first
// miss fills, which would not prove coalescing.
func TestCoalesceIdenticalMissesOneUpstreamGET(t *testing.T) {
const nClients = 8
body := []byte("coalesced depot chunk body")
var upstreamCalls atomic.Int64
release := make(chan struct{})
var releaseOnce sync.Once
releaseUpstream := func() { releaseOnce.Do(func() { close(release) }) }
t.Cleanup(releaseUpstream)
f := func(w http.ResponseWriter, r *http.Request) {
upstreamCalls.Add(1)
select {
case <-release:
case <-r.Context().Done():
return
}
w.Header().Set("Content-Type", "application/octet-stream")
_, _ = w.Write(body)
}
sc, _ := newTestCacheWithFakeUpstream(t, f, "1MB", "0")
sc.ResetMetrics()
const depotPath = "/depot/1684171/chunk/coalesce-inflight"
cacheKey, err := generateServiceCacheKey(depotPath, "steam")
if err != nil {
t.Fatal(err)
}
type clientResult struct {
status int
hdr string
body []byte
}
results := make([]clientResult, nClients)
var wg sync.WaitGroup
start := make(chan struct{})
wg.Add(nClients)
for i := 0; i < nClients; i++ {
go func(i int) {
defer wg.Done()
<-start
rec := httptest.NewRecorder()
sc.ServeHTTP(rec, steamCoalesceRequest(depotPath))
results[i] = clientResult{
status: rec.Code,
hdr: rec.Header().Get("X-LanCache-Status"),
body: rec.Body.Bytes(),
}
}(i)
}
close(start)
waitForCoalescerJoin(t, sc, cacheKey, nClients, releaseUpstream, &wg, &upstreamCalls)
releaseUpstream()
wg.Wait()
if got := upstreamCalls.Load(); got != 1 {
t.Fatalf("expected exactly 1 upstream GET, got %d", got)
}
var miss, coalesced int
for i, r := range results {
if r.status != http.StatusOK {
t.Errorf("client %d: expected 200, got %d", i, r.status)
}
if !bytes.Equal(r.body, body) {
t.Errorf("client %d: body mismatch: got %q", i, r.body)
}
switch r.hdr {
case "MISS":
miss++
case "HIT-COALESCED":
coalesced++
default:
t.Errorf("client %d: unexpected X-LanCache-Status %q", i, r.hdr)
}
}
if miss != 1 {
t.Errorf("expected 1 MISS leader, got %d", miss)
}
if coalesced != nClients-1 {
t.Errorf("expected %d HIT-COALESCED waiters, got %d", nClients-1, coalesced)
}
if got := sc.GetMetrics().CacheCoalesced; got < int64(nClients-1) {
t.Errorf("CacheCoalesced=%d, want >= %d", got, nClients-1)
}
}
// TestCoalesceIdenticalMissesSharedUpstreamError is the 5xx sibling: waiters
// share the leader's failure instead of each hitting origin. Upstream 500 is
// retried, so the call count is the leader's retry budget (not N).
func TestCoalesceIdenticalMissesSharedUpstreamError(t *testing.T) {
const nClients = 8
var upstreamCalls atomic.Int64
release := make(chan struct{})
var releaseOnce sync.Once
releaseUpstream := func() { releaseOnce.Do(func() { close(release) }) }
t.Cleanup(releaseUpstream)
f := func(w http.ResponseWriter, r *http.Request) {
upstreamCalls.Add(1)
select {
case <-release:
case <-r.Context().Done():
return
}
w.WriteHeader(http.StatusInternalServerError)
}
sc, _ := newTestCacheWithFakeUpstream(t, f, "1MB", "0")
sc.ResetMetrics()
const depotPath = "/depot/1684171/chunk/coalesce-inflight-err"
cacheKey, err := generateServiceCacheKey(depotPath, "steam")
if err != nil {
t.Fatal(err)
}
codes := make([]int, nClients)
var wg sync.WaitGroup
start := make(chan struct{})
wg.Add(nClients)
for i := 0; i < nClients; i++ {
go func(i int) {
defer wg.Done()
<-start
rec := httptest.NewRecorder()
sc.ServeHTTP(rec, steamCoalesceRequest(depotPath))
codes[i] = rec.Code
}(i)
}
close(start)
waitForCoalescerJoin(t, sc, cacheKey, nClients, releaseUpstream, &wg, &upstreamCalls)
releaseUpstream()
wg.Wait()
if got := upstreamCalls.Load(); got < 1 || got >= int64(nClients) {
t.Fatalf("expected coalesced origin GETs (leader + retries, < %d waiters), got %d", nClients, got)
}
for i, code := range codes {
if code != http.StatusInternalServerError {
t.Errorf("client %d: expected 500, got %d", i, code)
}
}
if got := sc.GetMetrics().Errors; got < int64(nClients) {
t.Errorf("Errors=%d, want >= %d (once per client)", got, nClients)
}
}
+45 -12
View File
@@ -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
View File
@@ -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
+221 -80
View File
@@ -7,17 +7,22 @@ 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
@@ -25,12 +30,16 @@ type Metrics struct {
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
@@ -73,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)
@@ -114,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)
@@ -138,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) }
@@ -183,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)
@@ -224,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)
@@ -240,62 +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
TotalBytesServed int64
TotalBytesSaved int64
MemoryCacheSize int64
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)
}
+240
View File
@@ -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
}
+222
View File
@@ -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)
}
}
+398
View File
@@ -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)
}
}
+42
View File
@@ -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
View File
@@ -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
View File
@@ -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)
}
})
}
+4 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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")
}
}
+14
View File
@@ -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)
}
+58
View File
@@ -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)
}
}
+17
View File
@@ -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)
}
+56
View File
@@ -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
View File
@@ -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
}
+50
View File
@@ -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)
+11
View File
@@ -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{
+28
View File
@@ -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)