test(cache): 添加 stale_if_error 和 stale_if_timeout 测试

覆盖 ProxyCache、DiskCache、TieredCache 的 GetStale 方法,
测试场景包括:错误时可用、超时时可用、窗口过期不可用、未过期直接返回。
同步更新 NewProxyCache 调用签名。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
xfy 2026-04-24 10:07:27 +08:00
parent be974b2e18
commit 0de153bb24
5 changed files with 302 additions and 29 deletions

View File

@ -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++ {

View File

@ -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=truestaleIfTimeout=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=falsestaleIfError=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")
}
}

View File

@ -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=truestaleIfTimeout=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=falsestaleIfError=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")
}
}

View File

@ -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)

View File

@ -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=truestaleIfTimeout=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")
}
}