diff --git a/internal/imgcache/eviction_internal_test.go b/internal/imgcache/eviction_internal_test.go index 7d54df1..c08ace4 100644 --- a/internal/imgcache/eviction_internal_test.go +++ b/internal/imgcache/eviction_internal_test.go @@ -9,7 +9,9 @@ import ( "log/slog" "os" "path/filepath" + "strconv" "strings" + "sync/atomic" "testing" "time" @@ -918,6 +920,84 @@ func TestPeriodicReconciliationAdoptsFileThatAppearsAfterStartup(t *testing.T) { } } +// TestEvictionTicksDoNotReconcile starts the eviction loop with an +// interval of 10 ms. The startup pass reconciles before it removes the +// expired negative cache rows, so once the expired row stored before the +// start is gone, the startup reconciliation has made all its reads. The +// test then waits for three periodic ticks, each seen when it removes an +// expired row stored after the previous one went. The reconciliation +// pass runs at startup and then once a day, so none of those ticks may +// read a content table for reconciliation. +func TestEvictionTicksDoNotReconcile(t *testing.T) { + t.Parallel() + + cache, _ := newEvictionTestCache(t, 1<<30) + + var reconciliationReads atomic.Int64 + + cache.reconciliationReadTestHook = func(int) { reconciliationReads.Add(1) } + + insertExpiredNegativeCacheRow(t, cache, "/startup.jpg") + + cache.StartEviction(10 * time.Millisecond) + defer func() { _ = cache.StopEviction(t.Context()) }() + + waitForNegativeCacheRowRemoved(t, cache, "/startup.jpg") + + startupReads := reconciliationReads.Load() + t.Logf("the startup pass made %d reconciliation reads", startupReads) + + if startupReads == 0 { + t.Fatal("the startup pass made no reconciliation read") + } + + for tick := range 3 { + path := "/tick-" + strconv.Itoa(tick) + ".jpg" + + insertExpiredNegativeCacheRow(t, cache, path) + waitForNegativeCacheRowRemoved(t, cache, path) + } + + tickReads := reconciliationReads.Load() - startupReads + t.Logf("three eviction ticks made %d reconciliation reads", tickReads) + + if tickReads != 0 { + t.Errorf("three eviction ticks made %d reconciliation reads, want 0: "+ + "the reconciliation pass ran on an eviction tick", tickReads) + } +} + +// insertExpiredNegativeCacheRow adds a negative_cache row for sourcePath +// that expired an hour ago. +func insertExpiredNegativeCacheRow(t *testing.T, cache *Cache, sourcePath string) { + t.Helper() + + _, 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, sourcePath) + if err != nil { + t.Fatalf("failed to insert an expired negative cache row: %v", err) + } +} + +// waitForNegativeCacheRowRemoved waits up to five seconds for the +// eviction loop to remove the negative_cache row for sourcePath. +func waitForNegativeCacheRowRemoved(t *testing.T, cache *Cache, sourcePath string) { + t.Helper() + + deadline := time.Now().Add(5 * time.Second) + + for negativeCacheRowCount(t, cache, sourcePath) > 0 { + if time.Now().After(deadline) { + t.Fatalf("the expired negative cache row for %s was not removed", sourcePath) + } + + time.Sleep(10 * time.Millisecond) + } +} + // TestStopEvictionInterruptsPassInProgress holds the test database's // only connection, so the startup reconciliation pass waits for it, and // checks that StopEviction stops that pass instead of waiting for the