fix: align cache controls with config

Remove unused head-check config, make TTL overrides explicit, and tighten revalidation to avoid stale cache behavior.
This commit is contained in:
2026-01-26 15:55:03 +08:00
parent 9a57949147
commit 40c6f2fcce
12 changed files with 58 additions and 38 deletions

View File

@@ -21,7 +21,6 @@ Type = "docker"
Username = "" Username = ""
Password = "" Password = ""
CacheTTL = 43200 CacheTTL = 43200
EnableHeadCheck = true
[[Hub]] [[Hub]]
Name = "composer-cache" Name = "composer-cache"

View File

@@ -19,4 +19,3 @@ Module = "legacy"
Username = "" Username = ""
Password = "" Password = ""
CacheTTL = 43200 CacheTTL = 43200
EnableHeadCheck = true

View File

@@ -19,4 +19,3 @@ Module = "legacy"
Username = "" Username = ""
Password = "" Password = ""
CacheTTL = 43200 CacheTTL = 43200
EnableHeadCheck = false

View File

@@ -64,16 +64,15 @@ type GlobalConfig struct {
// HubConfig 决定单个代理实例如何与下游/上游交互。 // HubConfig 决定单个代理实例如何与下游/上游交互。
type HubConfig struct { type HubConfig struct {
Name string `mapstructure:"Name"` Name string `mapstructure:"Name"`
Domain string `mapstructure:"Domain"` Domain string `mapstructure:"Domain"`
Upstream string `mapstructure:"Upstream"` Upstream string `mapstructure:"Upstream"`
Proxy string `mapstructure:"Proxy"` Proxy string `mapstructure:"Proxy"`
Type string `mapstructure:"Type"` Type string `mapstructure:"Type"`
Username string `mapstructure:"Username"` Username string `mapstructure:"Username"`
Password string `mapstructure:"Password"` Password string `mapstructure:"Password"`
CacheTTL Duration `mapstructure:"CacheTTL"` CacheTTL Duration `mapstructure:"CacheTTL"`
ValidationMode string `mapstructure:"ValidationMode"` ValidationMode string `mapstructure:"ValidationMode"`
EnableHeadCheck bool `mapstructure:"EnableHeadCheck"`
} }
// Config 是 TOML 文件映射的整体结构。 // Config 是 TOML 文件映射的整体结构。
@@ -109,11 +108,12 @@ func CredentialModes(hubs []HubConfig) []string {
// StrategyOverrides 将 hub 层的 TTL/Validation 配置映射为模块策略覆盖项。 // StrategyOverrides 将 hub 层的 TTL/Validation 配置映射为模块策略覆盖项。
func (h HubConfig) StrategyOverrides(ttl time.Duration) hubmodule.StrategyOptions { func (h HubConfig) StrategyOverrides(ttl time.Duration) hubmodule.StrategyOptions {
opts := hubmodule.StrategyOptions{ opts := hubmodule.StrategyOptions{}
TTLOverride: ttl,
}
if mode := strings.TrimSpace(h.ValidationMode); mode != "" { if mode := strings.TrimSpace(h.ValidationMode); mode != "" {
opts.ValidationOverride = hubmodule.ValidationMode(mode) opts.ValidationOverride = hubmodule.ValidationMode(mode)
} }
if h.CacheTTL.DurationValue() > 0 {
opts.TTLOverride = ttl
}
return opts return opts
} }

View File

@@ -37,7 +37,14 @@ func cachePolicy(_ *hooks.RequestContext, locatorPath string, current hooks.Cach
// 索引类Release/Packages/Contents需要 If-None-Match/If-Modified-Since 再验证。 // 索引类Release/Packages/Contents需要 If-None-Match/If-Modified-Since 再验证。
if strings.HasSuffix(clean, "/release") || if strings.HasSuffix(clean, "/release") ||
strings.HasSuffix(clean, "/inrelease") || strings.HasSuffix(clean, "/inrelease") ||
strings.HasSuffix(clean, "/release.gpg") { strings.HasSuffix(clean, "/release.gpg") ||
strings.HasSuffix(clean, "/packages") ||
strings.HasSuffix(clean, "/packages.gz") ||
strings.HasSuffix(clean, "/packages.xz") ||
strings.HasSuffix(clean, "/sources") ||
strings.HasSuffix(clean, "/sources.gz") ||
strings.HasSuffix(clean, "/sources.xz") ||
strings.Contains(clean, "/contents-") {
current.AllowCache = true current.AllowCache = true
current.AllowStore = true current.AllowStore = true
current.RequireRevalidate = true current.RequireRevalidate = true

View File

@@ -11,13 +11,14 @@ type StrategyOptions struct {
// ResolveStrategy 将模块的默认策略与 hub 级覆盖合并。 // ResolveStrategy 将模块的默认策略与 hub 级覆盖合并。
func ResolveStrategy(meta ModuleMetadata, opts StrategyOptions) CacheStrategyProfile { func ResolveStrategy(meta ModuleMetadata, opts StrategyOptions) CacheStrategyProfile {
strategy := meta.CacheStrategy strategy := meta.CacheStrategy
if strategy.TTLHint > 0 && opts.TTLOverride > 0 {
strategy.TTLHint = opts.TTLOverride
}
if opts.ValidationOverride != "" { if opts.ValidationOverride != "" {
strategy.ValidationMode = opts.ValidationOverride strategy.ValidationMode = opts.ValidationOverride
} }
return normalizeStrategy(strategy) strategy = normalizeStrategy(strategy)
if opts.TTLOverride > 0 {
strategy.TTLHint = opts.TTLOverride
}
return strategy
} }
func normalizeStrategy(profile CacheStrategyProfile) CacheStrategyProfile { func normalizeStrategy(profile CacheStrategyProfile) CacheStrategyProfile {

View File

@@ -3,7 +3,7 @@ package proxy
import ( import (
"bytes" "bytes"
"context" "context"
"crypto/sha1" "crypto/sha256"
"encoding/base64" "encoding/base64"
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
@@ -202,10 +202,25 @@ func (h *Handler) serveCache(
c.Status(status) c.Status(status)
if method == http.MethodHead { if method == http.MethodHead {
// 对 HEAD 请求仍向上游发起一次 HEAD以满足“显式请求 + 再验证”的期望。
if route != nil && route.UpstreamURL != nil { if route != nil && route.UpstreamURL != nil {
if resp, err := h.revalidateRequest(c, route, resolveUpstreamURL(route, route.UpstreamURL, c, hook), result.Entry.Locator, ""); err == nil { shouldRevalidate := true
resp.Body.Close() if hook != nil && hook.hasHooks && hook.def.CachePolicy != nil {
policy := hook.def.CachePolicy(hook.ctx, result.Entry.Locator.Path, hooks.CachePolicy{
AllowCache: true,
AllowStore: true,
RequireRevalidate: true,
})
if !policy.AllowCache || !policy.AllowStore {
shouldRevalidate = false
} else {
shouldRevalidate = policy.RequireRevalidate
}
}
if shouldRevalidate {
if resp, err := h.revalidateRequest(c, route, resolveUpstreamURL(route, route.UpstreamURL, c, hook), result.Entry.Locator, ""); err == nil {
resp.Body.Close()
}
} }
} }
result.Reader.Close() result.Reader.Close()
@@ -571,7 +586,7 @@ func resolveContentType(route *server.HubRoute, locator cache.Locator, hook *hoo
func buildLocator(route *server.HubRoute, c fiber.Ctx, clean string, rawQuery []byte) cache.Locator { func buildLocator(route *server.HubRoute, c fiber.Ctx, clean string, rawQuery []byte) cache.Locator {
query := rawQuery query := rawQuery
if len(query) > 0 { if len(query) > 0 {
sum := sha1.Sum(query) sum := sha256.Sum256(query)
clean = fmt.Sprintf("%s/__qs/%s", clean, hex.EncodeToString(sum[:])) clean = fmt.Sprintf("%s/__qs/%s", clean, hex.EncodeToString(sum[:]))
} }
loc := cache.Locator{ loc := cache.Locator{
@@ -791,7 +806,7 @@ func (h *Handler) isCacheFresh(
return true, nil return true, nil
case http.StatusOK: case http.StatusOK:
if resp.Header.Get("Etag") == "" && resp.Header.Get("Docker-Content-Digest") == "" && resp.Header.Get("Last-Modified") == "" { if resp.Header.Get("Etag") == "" && resp.Header.Get("Docker-Content-Digest") == "" && resp.Header.Get("Last-Modified") == "" {
return true, nil return false, nil
} }
h.rememberETag(route, locator, resp) h.rememberETag(route, locator, resp)
remote := extractModTime(resp.Header) remote := extractModTime(resp.Header)

View File

@@ -15,11 +15,10 @@ func TestHubRegistryLookupByHost(t *testing.T) {
}, },
Hubs: []config.HubConfig{ Hubs: []config.HubConfig{
{ {
Name: "docker", Name: "docker",
Domain: "docker.hub.local", Domain: "docker.hub.local",
Type: "docker", Type: "docker",
Upstream: "https://registry-1.docker.io", Upstream: "https://registry-1.docker.io",
EnableHeadCheck: true,
}, },
{ {
Name: "npm", Name: "npm",

View File

@@ -28,7 +28,6 @@
- `Upstream` (string, required, http/https URL) - `Upstream` (string, required, http/https URL)
- `Proxy` (string, optional, URL) - `Proxy` (string, optional, URL)
- `CacheTTL` (duration, optional, overrides global) - `CacheTTL` (duration, optional, overrides global)
- `EnableHeadCheck` (bool, optional, default true)
- **Validation Rules**: `Name` 必须唯一;`Domain` + `Port` 组合不得冲突URL 必须可解析。 - **Validation Rules**: `Name` 必须唯一;`Domain` + `Port` 组合不得冲突URL 必须可解析。
- **Relationships**: 属于 `Config`,在运行时用于初始化路由、缓存目录 `StoragePath/<Name>` - **Relationships**: 属于 `Config`,在运行时用于初始化路由、缓存目录 `StoragePath/<Name>`

View File

@@ -4,7 +4,7 @@
### HubRoute ### HubRoute
- **Description**: Host/端口到上游仓库的映射,供 Fiber 路由和 Proxy handler 使用。 - **Description**: Host/端口到上游仓库的映射,供 Fiber 路由和 Proxy handler 使用。
- **Fields**: `Name` (string, unique), `Domain` (string, FQDN), `Port` (int, 1-65535), `Upstream` (URL), `Proxy` (URL, optional), `CacheTTL` (duration override), `EnableHeadCheck` (bool). - **Fields**: `Name` (string, unique), `Domain` (string, FQDN), `Port` (int, 1-65535), `Upstream` (URL), `Proxy` (URL, optional), `CacheTTL` (duration override).
- **Validation**: Name 唯一Domain 不含协议/路径Upstream 必须 http/https。 - **Validation**: Name 唯一Domain 不含协议/路径Upstream 必须 http/https。
- **Relationships**: 由 config 加载到 `HubRegistry`;与 CacheEntry、ProxyRequest 通过 `Name` 关联。 - **Relationships**: 由 config 加载到 `HubRegistry`;与 CacheEntry、ProxyRequest 通过 `Name` 关联。

View File

@@ -68,8 +68,8 @@ func TestCacheStrategyOverrides(t *testing.T) {
} }
resp2.Body.Close() resp2.Body.Close()
if headCount := countRequests(stub.Requests(), http.MethodHead, "/lodash"); headCount != 0 { if headCount := countRequests(stub.Requests(), http.MethodHead, "/lodash"); headCount != 1 {
t.Fatalf("expected no HEAD before TTL expiry, got %d", headCount) t.Fatalf("expected single HEAD before TTL expiry, got %d", headCount)
} }
if getCount := countRequests(stub.Requests(), http.MethodGet, "/lodash"); getCount != 1 { if getCount := countRequests(stub.Requests(), http.MethodGet, "/lodash"); getCount != 1 {
t.Fatalf("upstream should be hit once before TTL expiry, got %d", getCount) t.Fatalf("upstream should be hit once before TTL expiry, got %d", getCount)
@@ -85,8 +85,8 @@ func TestCacheStrategyOverrides(t *testing.T) {
} }
resp3.Body.Close() resp3.Body.Close()
if headCount := countRequests(stub.Requests(), http.MethodHead, "/lodash"); headCount != 1 { if headCount := countRequests(stub.Requests(), http.MethodHead, "/lodash"); headCount != 2 {
t.Fatalf("expected single HEAD after TTL expiry, got %d", headCount) t.Fatalf("expected two HEAD requests after TTL expiry, got %d", headCount)
} }
if getCount := countRequests(stub.Requests(), http.MethodGet, "/lodash"); getCount != 1 { if getCount := countRequests(stub.Requests(), http.MethodGet, "/lodash"); getCount != 1 {
t.Fatalf("upstream GET count should remain 1, got %d", getCount) t.Fatalf("upstream GET count should remain 1, got %d", getCount)

View File

@@ -156,6 +156,7 @@ func registerDockerHandlers(mux *http.ServeMux, blob []byte) {
func registerNPMHandlers(mux *http.ServeMux) { func registerNPMHandlers(mux *http.ServeMux) {
mux.HandleFunc("/lodash", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("/lodash", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.Header().Set("Last-Modified", time.Now().UTC().Format(http.TimeFormat))
resp := map[string]any{ resp := map[string]any{
"name": "lodash", "name": "lodash",
"dist-tags": map[string]string{ "dist-tags": map[string]string{
@@ -174,6 +175,7 @@ func registerNPMHandlers(mux *http.ServeMux) {
mux.HandleFunc("/lodash/-/lodash-4.17.21.tgz", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("/lodash/-/lodash-4.17.21.tgz", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/octet-stream") w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Last-Modified", time.Now().UTC().Format(http.TimeFormat))
_, _ = w.Write([]byte("tarball-bytes")) _, _ = w.Write([]byte("tarball-bytes"))
}) })
} }