cache: Short TTL negative cache for 404/410 depot objects
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:
+38
-12
@@ -20,10 +20,12 @@ import (
|
||||
//
|
||||
// On-disk format (documented here at top of format.go per Phase 2 plan; stable v1):
|
||||
// File = header-line + raw-response-bytes
|
||||
// header-line = "SC2C " + 64hex(bodySHA256) + " " + strconv(len(rawResp)) + "\n"
|
||||
// raw-response-bytes = the exact bytes from reconstructRawResponse (HTTP/1.1 status\r\n + headers\r\n\r\n + body)
|
||||
// deserializeCacheFile: parses header, verifies size+SHA, returns CacheFileFormat.
|
||||
// No compression or extra fields. filterHopByHopHeaders is the shared helper
|
||||
// header-line = "SC2C " + 64hex(bodySHA256) + " " + strconv(len(rawResp)) [+ " " + expires-unix] + "\n"
|
||||
// Positive objects keep 3 fields. Negative (404/410) entries add an optional 4th
|
||||
// expires-unix field (seconds since epoch); deserialize treats 3-field files as
|
||||
// non-expiring. raw-response-bytes = reconstructRawResponse (HTTP/1.1 status\r\n
|
||||
// + headers\r\n\r\n + body). deserializeCacheFile: parses header, verifies size+SHA,
|
||||
// returns CacheFileFormat. No compression. filterHopByHopHeaders is the shared helper
|
||||
// (used in streamCachedResponse, handler MISS, coalescing.complete).
|
||||
const (
|
||||
CacheFileMagic = "SC2C" // SteamCache2 Cache
|
||||
@@ -34,11 +36,19 @@ type CacheFileFormat struct {
|
||||
ContentHash string // SHA256 hash of the response body (internal)
|
||||
ResponseSize int64 // Size of the entire HTTP response
|
||||
Response []byte // The entire HTTP response as raw bytes
|
||||
ExpiresUnix int64 // 0 = no expiry (positive object); >0 = negative-entry expiry (unix seconds)
|
||||
}
|
||||
|
||||
// serializeRawResponse serializes a raw HTTP response into our text-based cache format
|
||||
// upstreamHash and upstreamAlgo are used for verification during download but not stored
|
||||
// (positive object: 3-field SC2C header, no expiry).
|
||||
func serializeRawResponse(rawResponse []byte) ([]byte, error) {
|
||||
return serializeCacheFile(rawResponse, 0)
|
||||
}
|
||||
|
||||
// serializeCacheFile writes the SC2C header plus raw response. expiresUnix > 0
|
||||
// adds a 4th header field used for 404/410 negative entries; 0 keeps the
|
||||
// 3-field positive layout so existing cache files stay valid.
|
||||
func serializeCacheFile(rawResponse []byte, expiresUnix int64) ([]byte, error) {
|
||||
// Extract body from raw response for hash calculation
|
||||
bodyStart := bytes.Index(rawResponse, []byte("\r\n\r\n"))
|
||||
if bodyStart == -1 {
|
||||
@@ -53,8 +63,13 @@ func serializeRawResponse(rawResponse []byte) ([]byte, error) {
|
||||
// Create text-based cache file
|
||||
var buf bytes.Buffer
|
||||
|
||||
// First line: magic number, content hash, response size
|
||||
headerLine := fmt.Sprintf("%s %s %d\n", CacheFileMagic, contentHash, len(rawResponse))
|
||||
// First line: magic number, content hash, response size [, expires-unix]
|
||||
var headerLine string
|
||||
if expiresUnix > 0 {
|
||||
headerLine = fmt.Sprintf("%s %s %d %d\n", CacheFileMagic, contentHash, len(rawResponse), expiresUnix)
|
||||
} else {
|
||||
headerLine = fmt.Sprintf("%s %s %d\n", CacheFileMagic, contentHash, len(rawResponse))
|
||||
}
|
||||
buf.WriteString(headerLine)
|
||||
|
||||
// Rest of the file: raw HTTP response
|
||||
@@ -75,11 +90,11 @@ func deserializeCacheFile(data []byte) (*CacheFileFormat, error) {
|
||||
return nil, fmt.Errorf("invalid cache file format: no header line found")
|
||||
}
|
||||
|
||||
// Parse header line: "SC2C <hash> <size>"
|
||||
// Parse header line: "SC2C <hash> <size>" or "SC2C <hash> <size> <expires-unix>"
|
||||
headerLine := string(data[:newlineIndex])
|
||||
parts := strings.Fields(headerLine)
|
||||
if len(parts) != 3 {
|
||||
return nil, fmt.Errorf("invalid header format: expected 3 fields, got %d", len(parts))
|
||||
if len(parts) != 3 && len(parts) != 4 {
|
||||
return nil, fmt.Errorf("invalid header format: expected 3 or 4 fields, got %d", len(parts))
|
||||
}
|
||||
|
||||
// Check magic number
|
||||
@@ -99,6 +114,14 @@ func deserializeCacheFile(data []byte) (*CacheFileFormat, error) {
|
||||
return nil, fmt.Errorf("invalid response size: %w", err)
|
||||
}
|
||||
|
||||
var expiresUnix int64
|
||||
if len(parts) == 4 {
|
||||
expiresUnix, err = strconv.ParseInt(parts[3], 10, 64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid expires unix: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Extract raw response (everything after the header line)
|
||||
rawResponse := data[newlineIndex+1:]
|
||||
|
||||
@@ -128,6 +151,7 @@ func deserializeCacheFile(data []byte) (*CacheFileFormat, error) {
|
||||
ContentHash: contentHash,
|
||||
ResponseSize: responseSize,
|
||||
Response: rawResponse,
|
||||
ExpiresUnix: expiresUnix,
|
||||
}
|
||||
|
||||
return cacheFile, nil
|
||||
@@ -222,9 +246,11 @@ func (sc *SteamCache) streamCachedResponse(w http.ResponseWriter, r *http.Reques
|
||||
bodyStart := responseReader.Size() - int64(responseReader.Len())
|
||||
bodyData := cacheFile.Response[bodyStart:]
|
||||
|
||||
// Handle Range requests
|
||||
// Handle Range requests on cached 200 bodies only. Cached 404/410 (negative
|
||||
// entries) are served as the stored status; slicing an error body as 206
|
||||
// would be wrong.
|
||||
rangeHeader := r.Header.Get("Range")
|
||||
if rangeHeader != "" {
|
||||
if rangeHeader != "" && statusCode == http.StatusOK {
|
||||
// Parse the range request
|
||||
start, end, totalSize, valid := parseRangeHeader(rangeHeader, int64(len(bodyData)))
|
||||
if !valid {
|
||||
|
||||
Reference in New Issue
Block a user