From 01434ecf3bf34c9fe83b56088b6c0edbf60968e7 Mon Sep 17 00:00:00 2001 From: sneak Date: Thu, 15 Oct 2026 03:30:04 +0000 Subject: [PATCH] Test that expired negative cache rows are deleted in batches One statement deletes one batch and no more, CleanExpired removes the rest, and it deletes nothing once its context has ended. Model: opus-5-5 --- .../imgcache/negative_cache_internal_test.go | 115 ++++++++++++++++++ 1 file changed, 115 insertions(+) diff --git a/internal/imgcache/negative_cache_internal_test.go b/internal/imgcache/negative_cache_internal_test.go index ded267c..3f7c9c2 100644 --- a/internal/imgcache/negative_cache_internal_test.go +++ b/internal/imgcache/negative_cache_internal_test.go @@ -3,6 +3,7 @@ package imgcache import ( "context" "errors" + "strconv" "testing" "time" ) @@ -185,6 +186,120 @@ func negativeCacheRowCount(tb testing.TB, cache *Cache, sourcePath string) int { return count } +// TestCleanExpiredDeletesInBatches stores one expired negative cache row +// more than one batch holds, and one row that has not expired. One +// statement must delete one batch and no more, so a request waits behind +// one batch at most; CleanExpired must then remove the expired row left +// over and keep the row that has not expired. +func TestCleanExpiredDeletesInBatches(t *testing.T) { + t.Parallel() + + cache, err := NewCache(evictionTestDB(t), CacheConfig{StateDir: t.TempDir()}) + if err != nil { + t.Fatalf("failed to create cache: %v", err) + } + + ctx := t.Context() + + insertExpiredNegativeCacheRows(t, cache, cleanExpiredBatchSize+1) + + const notExpiredPath = "/later.jpg" + + _, err = cache.db.ExecContext(ctx, ` + 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) + } + + deleted, err := cache.cleanExpiredBatch(ctx) + if err != nil { + t.Fatalf("cleanExpiredBatch failed: %v", err) + } + + t.Logf("one statement deleted %d of %d expired rows", + deleted, cleanExpiredBatchSize+1) + + if deleted != cleanExpiredBatchSize { + t.Errorf("one statement deleted %d rows, want %d", + deleted, cleanExpiredBatchSize) + } + + err = cache.CleanExpired(ctx) + if err != nil { + t.Fatalf("CleanExpired failed: %v", err) + } + + if count := negativeCacheTableRowCount(t, cache); count != 1 { + t.Errorf("negative_cache holds %d rows after CleanExpired, want 1", count) + } + + if negativeCacheRowCount(t, cache, notExpiredPath) != 1 { + t.Error("the row that has not expired was removed") + } +} + +// TestCleanExpiredStopsOnceContextEnds runs CleanExpired with a context +// that has already ended: it must return the context's error and delete +// no row. +func TestCleanExpiredStopsOnceContextEnds(t *testing.T) { + t.Parallel() + + cache, err := NewCache(evictionTestDB(t), CacheConfig{StateDir: t.TempDir()}) + if err != nil { + t.Fatalf("failed to create cache: %v", err) + } + + insertExpiredNegativeCacheRows(t, cache, 1) + + ctx, cancel := context.WithCancel(t.Context()) + cancel() + + err = cache.CleanExpired(ctx) + if !errors.Is(err, context.Canceled) { + t.Errorf("CleanExpired() error = %v, want context.Canceled", err) + } + + if count := negativeCacheTableRowCount(t, cache); count != 1 { + t.Errorf("negative_cache holds %d rows, want the expired row kept", count) + } +} + +// insertExpiredNegativeCacheRows inserts count negative_cache rows that +// expired an hour ago, each for a different source path. +func insertExpiredNegativeCacheRows(t *testing.T, cache *Cache, count int) { + t.Helper() + + for i := range count { + _, 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, "/expired/"+strconv.Itoa(i)+".jpg") + if err != nil { + t.Fatalf("failed to insert an expired row: %v", err) + } + } +} + +// negativeCacheTableRowCount returns how many rows negative_cache holds. +func negativeCacheTableRowCount(tb testing.TB, cache *Cache) int { + tb.Helper() + + var count int + + err := cache.db.QueryRowContext(tb.Context(), + `SELECT COUNT(*) FROM negative_cache`, + ).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()