diff --git a/internal/cache/cache_bench_test.go b/internal/cache/cache_bench_test.go index a629d64..7088043 100644 --- a/internal/cache/cache_bench_test.go +++ b/internal/cache/cache_bench_test.go @@ -194,7 +194,7 @@ func BenchmarkFileCacheLRUTouch(b *testing.B) { // BenchmarkProxyCacheGet 测试代理缓存 Get 性能。 func BenchmarkProxyCacheGet(b *testing.B) { - pc := NewProxyCache(nil, false, 0) + pc := NewProxyCache(nil, false, 0, 0, 0) // 预填充缓存 for i := 0; i < 1000; i++ { @@ -219,7 +219,7 @@ func BenchmarkProxyCacheGet(b *testing.B) { // BenchmarkProxyCacheSet 测试代理缓存 Set 性能。 func BenchmarkProxyCacheSet(b *testing.B) { - pc := NewProxyCache(nil, false, 0) + pc := NewProxyCache(nil, false, 0, 0, 0) data := []byte("response body") headers := map[string]string{"Content-Type": "application/json"} @@ -234,7 +234,7 @@ func BenchmarkProxyCacheSet(b *testing.B) { // BenchmarkProxyCacheConcurrent 测试代理缓存并发混合负载。 // 使用 90% Get / 10% Set 的混合负载。 func BenchmarkProxyCacheConcurrent(b *testing.B) { - pc := NewProxyCache(nil, false, 0) + pc := NewProxyCache(nil, false, 0, 0, 0) // 预填充缓存 for i := 0; i < 1000; i++ { diff --git a/internal/cache/cache_test.go b/internal/cache/cache_test.go index e82eb03..ba94463 100644 --- a/internal/cache/cache_test.go +++ b/internal/cache/cache_test.go @@ -89,7 +89,7 @@ func TestFileCacheLRUEviction(t *testing.T) { } func TestAcquireLockWithTimeout(t *testing.T) { - pc := NewProxyCache(nil, true, 0) + pc := NewProxyCache(nil, true, 0, 0, 0) key := hashKey("timeout-test") // 测试获取锁 @@ -114,7 +114,7 @@ func TestAcquireLockWithTimeout(t *testing.T) { } func TestRefreshTTL(t *testing.T) { - pc := NewProxyCache(nil, false, 0) + pc := NewProxyCache(nil, false, 0, 0, 0) key := hashKey("refresh-test") origKey := "refresh-test" @@ -140,7 +140,7 @@ func TestRefreshTTL(t *testing.T) { } func TestSetValidationHeaders(t *testing.T) { - pc := NewProxyCache(nil, false, 0) + pc := NewProxyCache(nil, false, 0, 0, 0) key := hashKey("validation-test") origKey := "validation-test" @@ -182,7 +182,7 @@ func TestMatchRulePathVariants(t *testing.T) { rules := []ProxyCacheRule{ {Path: tt.rulePath, Methods: []string{"GET"}, MaxAge: time.Minute}, } - pc := NewProxyCache(rules, false, 0) + pc := NewProxyCache(rules, false, 0, 0, 0) rule := pc.MatchRule(tt.reqPath, "GET", 0) if (rule != nil) != tt.want { t.Errorf("MatchRule(%s, %s) = %v, want %v", tt.rulePath, tt.reqPath, rule != nil, tt.want) @@ -192,7 +192,7 @@ func TestMatchRulePathVariants(t *testing.T) { } func TestMinUsesThreshold(t *testing.T) { - pc := NewProxyCache(nil, false, 0) + pc := NewProxyCache(nil, false, 0, 0, 0) key := hashKey("minuses-test") origKey := "minuses-test" @@ -283,14 +283,14 @@ func TestNewProxyCache(t *testing.T) { {Path: "/api/", Methods: []string{"GET"}, MaxAge: 10 * time.Minute}, } - pc := NewProxyCache(rules, true, 60*time.Second) + pc := NewProxyCache(rules, true, 60*time.Second, 0, 0) if pc == nil { t.Error("Expected non-nil ProxyCache") } } func TestProxyCacheSetGet(t *testing.T) { - pc := NewProxyCache(nil, false, 0) + pc := NewProxyCache(nil, false, 0, 0, 0) key := "test-key" data := []byte("response body") @@ -314,7 +314,7 @@ func TestProxyCacheSetGet(t *testing.T) { } func TestProxyCacheExpiration(t *testing.T) { - pc := NewProxyCache(nil, false, 0) + pc := NewProxyCache(nil, false, 0, 0, 0) key := "expire-test" pc.Set(hashKey(key), key, []byte("data"), nil, 200, 100*time.Millisecond) @@ -335,7 +335,7 @@ func TestProxyCacheExpiration(t *testing.T) { } func TestProxyCacheStaleWhileRevalidate(t *testing.T) { - pc := NewProxyCache(nil, false, 200*time.Millisecond) + pc := NewProxyCache(nil, false, 200*time.Millisecond, 0, 0) key := "stale-test" pc.Set(hashKey(key), key, []byte("data"), nil, 200, 100*time.Millisecond) @@ -356,7 +356,7 @@ func TestProxyCacheStaleWhileRevalidate(t *testing.T) { } func TestProxyCacheLock(t *testing.T) { - pc := NewProxyCache(nil, true, 0) + pc := NewProxyCache(nil, true, 0, 0, 0) key := "lock-test" @@ -388,7 +388,7 @@ func TestProxyCacheMatchRule(t *testing.T) { {Path: "/static/*", Methods: []string{"GET"}, MaxAge: 1 * time.Hour}, } - pc := NewProxyCache(rules, false, 0) + pc := NewProxyCache(rules, false, 0, 0, 0) tests := []struct { path string @@ -416,7 +416,7 @@ func TestProxyCacheMatchRule(t *testing.T) { } func TestProxyCacheDelete(t *testing.T) { - pc := NewProxyCache(nil, false, 0) + pc := NewProxyCache(nil, false, 0, 0, 0) key := "key1" pc.Set(hashKey(key), key, []byte("data"), nil, 200, 10*time.Minute) @@ -429,7 +429,7 @@ func TestProxyCacheDelete(t *testing.T) { } func TestProxyCacheClear(t *testing.T) { - pc := NewProxyCache(nil, false, 0) + pc := NewProxyCache(nil, false, 0, 0, 0) pc.Set(hashKey("a"), "a", []byte("a"), nil, 200, 10*time.Minute) pc.Set(hashKey("b"), "b", []byte("b"), nil, 200, 10*time.Minute) @@ -470,3 +470,93 @@ func TestPathMatch(t *testing.T) { }) } } + +func TestProxyCacheGetStaleIfError(t *testing.T) { + pc := NewProxyCache(nil, false, 0, 200*time.Millisecond, 0) + + key := "stale-error-test" + pc.Set(hashKey(key), key, []byte("data"), nil, 200, 100*time.Millisecond) + + // 等待过期但仍在 stale_if_error 窗口内 + time.Sleep(150 * time.Millisecond) + + // isTimeout=false,应该使用 staleIfError 窗口 + entry, ok := pc.GetStale(hashKey(key), key, false) + if !ok { + t.Error("stale entry should be usable on error") + } + if entry == nil { + t.Error("expected stale entry data") + } + if string(entry.Data) != "data" { + t.Errorf("entry.Data = %q, want %q", entry.Data, "data") + } + + // isTimeout=true,staleIfTimeout=0,不应该可用 + if _, ok2 := pc.GetStale(hashKey(key), key, true); ok2 { + t.Error("stale entry should NOT be usable on timeout when staleIfTimeout=0") + } +} + +func TestProxyCacheGetStaleIfTimeout(t *testing.T) { + pc := NewProxyCache(nil, false, 0, 0, 300*time.Millisecond) + + key := "stale-timeout-test" + pc.Set(hashKey(key), key, []byte("data"), nil, 200, 100*time.Millisecond) + + // 等待过期但仍在 stale_if_timeout 窗口内 + time.Sleep(250 * time.Millisecond) + + // isTimeout=true,应该使用 staleIfTimeout 窗口 + entry, ok := pc.GetStale(hashKey(key), key, true) + if !ok { + t.Error("stale entry should be usable on timeout") + } + if entry == nil { + t.Error("expected stale entry data") + } + + // isTimeout=false,staleIfError=0,不应该可用 + if _, ok2 := pc.GetStale(hashKey(key), key, false); ok2 { + t.Error("stale entry should NOT be usable on error when staleIfError=0") + } +} + +func TestProxyCacheGetStaleExpired(t *testing.T) { + pc := NewProxyCache(nil, false, 0, 100*time.Millisecond, 100*time.Millisecond) + + key := "stale-expired-test" + pc.Set(hashKey(key), key, []byte("data"), nil, 200, 50*time.Millisecond) + + // 等待超过 stale 窗口 + time.Sleep(200 * time.Millisecond) + + // 两种情况都不应该可用 + if _, ok := pc.GetStale(hashKey(key), key, false); ok { + t.Error("stale entry should NOT be usable after stale window expired") + } + + if _, ok2 := pc.GetStale(hashKey(key), key, true); ok2 { + t.Error("stale entry should NOT be usable on timeout after stale window expired") + } +} + +func TestProxyCacheGetStaleNotExpired(t *testing.T) { + pc := NewProxyCache(nil, false, 0, 100*time.Millisecond, 100*time.Millisecond) + + key := "stale-fresh-test" + pc.Set(hashKey(key), key, []byte("data"), nil, 200, 200*time.Millisecond) + + // 未过期,两种情况都应该可用(返回新鲜数据) + entry, ok := pc.GetStale(hashKey(key), key, false) + if !ok { + t.Error("fresh entry should be usable") + } + if string(entry.Data) != "data" { + t.Errorf("entry.Data = %q, want %q", entry.Data, "data") + } + + if _, ok2 := pc.GetStale(hashKey(key), key, true); !ok2 { + t.Error("fresh entry should be usable on timeout") + } +} diff --git a/internal/cache/disk_cache_test.go b/internal/cache/disk_cache_test.go index 4a78f3c..2d01299 100644 --- a/internal/cache/disk_cache_test.go +++ b/internal/cache/disk_cache_test.go @@ -384,3 +384,114 @@ func TestDiskCacheRestart(t *testing.T) { t.Errorf("Data = %q, want %q", entry.Data, data) } } + +func TestDiskCacheGetStaleIfError(t *testing.T) { + tmpDir := t.TempDir() + + cfg := &DiskCacheConfig{ + Path: tmpDir, + Levels: "1:2", + StaleIfError: 200 * time.Millisecond, + StaleIfTimeout: 0, + } + + dc, err := NewDiskCache(cfg) + if err != nil { + t.Fatalf("NewDiskCache failed: %v", err) + } + defer dc.Stop() + <-dc.loadCh + + hashKey := uint64(12345) + origKey := "GET:/api/test" + dc.Set(hashKey, origKey, []byte("data"), nil, 200, 100*time.Millisecond) + + // 等待过期但仍在 stale_if_error 窗口内 + time.Sleep(150 * time.Millisecond) + + // isTimeout=false,应该使用 staleIfError 窗口 + entry, ok := dc.GetStale(hashKey, origKey, false) + if !ok { + t.Error("stale entry should be usable on error") + } + if entry == nil || string(entry.Data) != "data" { + t.Errorf("entry.Data = %v, want %q", entry, "data") + } + + // isTimeout=true,staleIfTimeout=0,不应该可用 + if _, ok2 := dc.GetStale(hashKey, origKey, true); ok2 { + t.Error("stale entry should NOT be usable on timeout when staleIfTimeout=0") + } +} + +func TestDiskCacheGetStaleIfTimeout(t *testing.T) { + tmpDir := t.TempDir() + + cfg := &DiskCacheConfig{ + Path: tmpDir, + Levels: "1:2", + StaleIfError: 0, + StaleIfTimeout: 300 * time.Millisecond, + } + + dc, err := NewDiskCache(cfg) + if err != nil { + t.Fatalf("NewDiskCache failed: %v", err) + } + defer dc.Stop() + <-dc.loadCh + + hashKey := uint64(12345) + origKey := "GET:/api/test" + dc.Set(hashKey, origKey, []byte("data"), nil, 200, 100*time.Millisecond) + + // 等待过期但仍在 stale_if_timeout 窗口内 + time.Sleep(250 * time.Millisecond) + + // isTimeout=true,应该使用 staleIfTimeout 窗口 + entry, ok := dc.GetStale(hashKey, origKey, true) + if !ok { + t.Error("stale entry should be usable on timeout") + } + if entry == nil { + t.Error("expected stale entry data") + } + + // isTimeout=false,staleIfError=0,不应该可用 + if _, ok2 := dc.GetStale(hashKey, origKey, false); ok2 { + t.Error("stale entry should NOT be usable on error when staleIfError=0") + } +} + +func TestDiskCacheGetStaleExpired(t *testing.T) { + tmpDir := t.TempDir() + + cfg := &DiskCacheConfig{ + Path: tmpDir, + Levels: "1:2", + StaleIfError: 100 * time.Millisecond, + StaleIfTimeout: 100 * time.Millisecond, + } + + dc, err := NewDiskCache(cfg) + if err != nil { + t.Fatalf("NewDiskCache failed: %v", err) + } + defer dc.Stop() + <-dc.loadCh + + hashKey := uint64(12345) + origKey := "GET:/api/test" + dc.Set(hashKey, origKey, []byte("data"), nil, 200, 50*time.Millisecond) + + // 等待超过 stale 窗口 + time.Sleep(200 * time.Millisecond) + + if _, ok := dc.GetStale(hashKey, origKey, false); ok { + t.Error("stale entry should NOT be usable after stale window expired") + } + + if _, ok2 := dc.GetStale(hashKey, origKey, true); ok2 { + t.Error("stale entry should NOT be usable on timeout after stale window expired") + } +} diff --git a/internal/cache/purge_test.go b/internal/cache/purge_test.go index 10c1116..ba149a9 100644 --- a/internal/cache/purge_test.go +++ b/internal/cache/purge_test.go @@ -109,7 +109,7 @@ func TestNewPurgeAPI(t *testing.T) { }) t.Run("with cache", func(t *testing.T) { - pc := NewProxyCache(nil, false, 0) + pc := NewProxyCache(nil, false, 0, 0, 0) cfg := &config.CacheAPIConfig{ Path: "/custom/purge", Allow: []string{"127.0.0.1"}, @@ -276,7 +276,7 @@ func TestPurgeAPI_ServeHTTP_AccessForbidden(t *testing.T) { } func TestPurgeAPI_ServeHTTP_Unauthorized(t *testing.T) { - pc := NewProxyCache(nil, false, 0) + pc := NewProxyCache(nil, false, 0, 0, 0) cfg := &config.CacheAPIConfig{ Allow: []string{"127.0.0.1"}, Auth: config.CacheAPIAuthConfig{ @@ -303,7 +303,7 @@ func TestPurgeAPI_ServeHTTP_Unauthorized(t *testing.T) { } func TestPurgeAPI_ServeHTTP_BadRequest(t *testing.T) { - pc := NewProxyCache(nil, false, 0) + pc := NewProxyCache(nil, false, 0, 0, 0) cfg := &config.CacheAPIConfig{ Allow: []string{}, } @@ -338,7 +338,7 @@ func TestPurgeAPI_ServeHTTP_BadRequest(t *testing.T) { } func TestPurgeAPI_ServeHTTP_PurgeByPath(t *testing.T) { - pc := NewProxyCache(nil, false, 0) + pc := NewProxyCache(nil, false, 0, 0, 0) key := "GET:/api/test" pc.Set(hashKey(key), key, []byte("data"), nil, 200, 10*60*time.Second) @@ -376,7 +376,7 @@ func TestPurgeAPI_ServeHTTP_PurgeByPath(t *testing.T) { } func TestPurgeAPI_ServeHTTP_PurgeByPath_NotFound(t *testing.T) { - pc := NewProxyCache(nil, false, 0) + pc := NewProxyCache(nil, false, 0, 0, 0) cfg := &config.CacheAPIConfig{ Allow: []string{}, } @@ -405,7 +405,7 @@ func TestPurgeAPI_ServeHTTP_PurgeByPath_NotFound(t *testing.T) { } func TestPurgeAPI_ServeHTTP_PurgeByPattern(t *testing.T) { - pc := NewProxyCache(nil, false, 0) + pc := NewProxyCache(nil, false, 0, 0, 0) pc.Set(hashKey("GET:/api/users"), "GET:/api/users", []byte("users"), nil, 200, 10*60*time.Second) pc.Set(hashKey("GET:/api/posts"), "GET:/api/posts", []byte("posts"), nil, 200, 10*60*time.Second) pc.Set(hashKey("GET:/static/css"), "GET:/static/css", []byte("css"), nil, 200, 10*60*time.Second) @@ -444,7 +444,7 @@ func TestPurgeAPI_ServeHTTP_PurgeByPattern(t *testing.T) { } func TestPurgeAPI_ServeHTTP_PurgeByPattern_Wildcard(t *testing.T) { - pc := NewProxyCache(nil, false, 0) + pc := NewProxyCache(nil, false, 0, 0, 0) pc.Set(hashKey("GET:/a"), "GET:/a", []byte("a"), nil, 200, 10*60*time.Second) pc.Set(hashKey("GET:/b"), "GET:/b", []byte("b"), nil, 200, 10*60*time.Second) @@ -472,7 +472,7 @@ func TestPurgeAPI_ServeHTTP_PurgeByPattern_Wildcard(t *testing.T) { } func TestPurgeAPI_ServeHTTP_PurgeByPattern_DirPrefix(t *testing.T) { - pc := NewProxyCache(nil, false, 0) + pc := NewProxyCache(nil, false, 0, 0, 0) pc.Set(hashKey("GET:/api/v1/users"), "GET:/api/v1/users", []byte("u"), nil, 200, 10*60*time.Second) pc.Set(hashKey("GET:/api/v2/posts"), "GET:/api/v2/posts", []byte("p"), nil, 200, 10*60*time.Second) pc.Set(hashKey("GET:/other"), "GET:/other", []byte("o"), nil, 200, 10*60*time.Second) @@ -501,7 +501,7 @@ func TestPurgeAPI_ServeHTTP_PurgeByPattern_DirPrefix(t *testing.T) { } func TestPurgeAPI_ServeHTTP_ContentType(t *testing.T) { - pc := NewProxyCache(nil, false, 0) + pc := NewProxyCache(nil, false, 0, 0, 0) cfg := &config.CacheAPIConfig{ Allow: []string{}, } @@ -535,7 +535,7 @@ func TestPurgeAPI_ServeHTTP_ContentType(t *testing.T) { } func TestPurgeAPI_ServeHTTP_AccessAllowed(t *testing.T) { - pc := NewProxyCache(nil, false, 0) + pc := NewProxyCache(nil, false, 0, 0, 0) cfg := &config.CacheAPIConfig{ Allow: []string{"10.0.0.0/8"}, } @@ -558,7 +558,7 @@ func TestPurgeAPI_ServeHTTP_AccessAllowed(t *testing.T) { } func TestPurgeAPI_ServeHTTP_TokenAuth(t *testing.T) { - pc := NewProxyCache(nil, false, 0) + pc := NewProxyCache(nil, false, 0, 0, 0) cfg := &config.CacheAPIConfig{ Allow: []string{}, Auth: config.CacheAPIAuthConfig{ @@ -599,7 +599,7 @@ func TestPurgeAPI_ServeHTTP_TokenAuth(t *testing.T) { } func TestPurgeAPI_ServeHTTP_AuthTypeNone(t *testing.T) { - pc := NewProxyCache(nil, false, 0) + pc := NewProxyCache(nil, false, 0, 0, 0) cfg := &config.CacheAPIConfig{ Allow: []string{}, Auth: config.CacheAPIAuthConfig{ @@ -711,7 +711,7 @@ func TestPurgeAPI_ErrorResponse(t *testing.T) { func TestPurgeAPI_PurgeByPath_WrongMethod(t *testing.T) { // Test that hashPath only uses GET, so purging a POST-cached entry won't work - pc := NewProxyCache(nil, false, 0) + pc := NewProxyCache(nil, false, 0, 0, 0) // Set a cache entry with GET:/api/test key pc.Set(hashKey("GET:/api/test"), "GET:/api/test", []byte("data"), nil, 200, 10*60*time.Second) diff --git a/internal/cache/tiered_cache_test.go b/internal/cache/tiered_cache_test.go index 86fac74..578f513 100644 --- a/internal/cache/tiered_cache_test.go +++ b/internal/cache/tiered_cache_test.go @@ -357,3 +357,75 @@ func TestTieredCacheCacheStats(t *testing.T) { t.Errorf("Entries = %d, should be >= 1", stats.Entries) } } + +func TestTieredCacheGetStaleL1Hit(t *testing.T) { + tmpDir := t.TempDir() + + cfg := &TieredCacheConfig{ + L1MaxEntries: 100, + L1MaxSize: 1024 * 1024, + L2Config: &DiskCacheConfig{ + Path: tmpDir, + Levels: "1:2", + }, + StaleIfError: 200 * time.Millisecond, + StaleIfTimeout: 0, + } + + tc, err := NewTieredCache(cfg) + if err != nil { + t.Fatalf("NewTieredCache failed: %v", err) + } + defer tc.Stop() + + hashKey := uint64(54321) + origKey := "GET:/api/tiered" + tc.Set(hashKey, origKey, []byte("l1data"), nil, 200, 100*time.Millisecond) + + // 等待过期但仍在 stale_if_error 窗口内 + time.Sleep(150 * time.Millisecond) + + // isTimeout=false,应该从 L1 获取 stale 缓存 + entry, ok := tc.GetStale(hashKey, origKey, false) + if !ok { + t.Error("stale entry should be usable on error from L1") + } + if entry == nil || string(entry.Data) != "l1data" { + t.Errorf("entry.Data = %v, want %q", entry, "l1data") + } + + // isTimeout=true,staleIfTimeout=0,不应该可用 + if _, ok2 := tc.GetStale(hashKey, origKey, true); ok2 { + t.Error("stale entry should NOT be usable on timeout when staleIfTimeout=0") + } +} + +func TestTieredCacheGetStaleMiss(t *testing.T) { + tmpDir := t.TempDir() + + cfg := &TieredCacheConfig{ + L1MaxEntries: 100, + L1MaxSize: 1024 * 1024, + L2Config: &DiskCacheConfig{ + Path: tmpDir, + Levels: "1:2", + }, + StaleIfError: 200 * time.Millisecond, + StaleIfTimeout: 200 * time.Millisecond, + } + + tc, err := NewTieredCache(cfg) + if err != nil { + t.Fatalf("NewTieredCache failed: %v", err) + } + defer tc.Stop() + + // 不存在的 key + if _, ok := tc.GetStale(99999, "nonexistent", false); ok { + t.Error("should not find nonexistent key") + } + + if _, ok2 := tc.GetStale(99999, "nonexistent", true); ok2 { + t.Error("should not find nonexistent key on timeout") + } +}