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).
This commit is contained in:
2026-09-08 16:40:03 +00:00
parent 8eb31143e5
commit 036ea1ea7f
10 changed files with 550 additions and 89 deletions
+38 -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 {