From c1c51c4e15103431eb7b36d6340ac51418477c58 Mon Sep 17 00:00:00 2001 From: sneak Date: Thu, 15 Oct 2026 02:11:52 +0000 Subject: [PATCH] Test that the eviction loop removes expired negative cache rows A failed fetch stored in the negative cache must be removed by the eviction loop once it has expired, although its URL is never requested again, with the disk cache enabled and disabled; a row that has not expired stays. Fails until the fix for https://git.eeqj.de/sneak/pixa/issues/244 lands. Model: opus-5-5 --- .../imgcache/negative_cache_internal_test.go | 91 +++++++++++++++++++ 1 file changed, 91 insertions(+) diff --git a/internal/imgcache/negative_cache_internal_test.go b/internal/imgcache/negative_cache_internal_test.go index f2c2115..ded267c 100644 --- a/internal/imgcache/negative_cache_internal_test.go +++ b/internal/imgcache/negative_cache_internal_test.go @@ -94,6 +94,97 @@ func TestNegativeCache_Expired(t *testing.T) { } } +// TestEvictionLoopRemovesExpiredNegativeCacheRows stores a failed fetch +// that expires a second later and starts the eviction loop with a short +// interval: a later tick must remove its row although its URL is never +// requested again, and must keep a row that has not expired. It runs +// with the disk cache enabled and disabled, as the negative cache is +// used either way. +func TestEvictionLoopRemovesExpiredNegativeCacheRows(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + disableDiskCache bool + }{ + {name: "disk cache enabled", disableDiskCache: false}, + {name: "disk cache disabled", disableDiskCache: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + cache, err := NewCache(evictionTestDB(t), CacheConfig{ + StateDir: t.TempDir(), + NegativeTTL: time.Second, + DisableDiskCache: tt.disableDiskCache, + }) + if err != nil { + t.Fatalf("failed to create cache: %v", err) + } + + expired := &ImageRequest{ + SourceHost: testHostExample, SourcePath: "/gone.jpg", + } + + err = cache.StoreNegative(t.Context(), expired, 404, "not found") + if err != nil { + t.Fatalf("StoreNegative failed: %v", err) + } + + const notExpiredPath = "/later.jpg" + + _, err = cache.db.ExecContext(t.Context(), ` + INSERT INTO negative_cache + (source_host, source_path, status_code, expires_at) + VALUES (?, ?, 404, datetime('now', '+1 hour')) + `, testHostExample, notExpiredPath) + if err != nil { + t.Fatalf("failed to insert a row that has not expired: %v", err) + } + + started := time.Now() + + cache.StartEviction(100 * time.Millisecond) + defer func() { _ = cache.StopEviction(t.Context()) }() + + for negativeCacheRowCount(t, cache, expired.SourcePath) > 0 { + if time.Since(started) > 10*time.Second { + t.Fatal("the expired row is still in negative_cache") + } + + time.Sleep(50 * time.Millisecond) + } + + t.Logf("the expired row was removed %s after the loop started", + time.Since(started)) + + if negativeCacheRowCount(t, cache, notExpiredPath) != 1 { + t.Error("the row that has not expired was removed") + } + }) + } +} + +// negativeCacheRowCount returns how many negative_cache rows hold +// sourcePath. +func negativeCacheRowCount(tb testing.TB, cache *Cache, sourcePath string) int { + tb.Helper() + + var count int + + err := cache.db.QueryRowContext(tb.Context(), + `SELECT COUNT(*) FROM negative_cache WHERE source_path = ?`, + sourcePath, + ).Scan(&count) + if err != nil { + tb.Fatalf("failed to count negative_cache rows: %v", err) + } + + return count +} + func TestService_Get_ReturnsErrorForNegativeCachedURL(t *testing.T) { t.Parallel()