diff --git a/internal/imgcache/eviction_internal_test.go b/internal/imgcache/eviction_internal_test.go index 68f0b10..cbc2b5c 100644 --- a/internal/imgcache/eviction_internal_test.go +++ b/internal/imgcache/eviction_internal_test.go @@ -6,8 +6,10 @@ import ( "database/sql" "errors" "io/fs" + "log/slog" "os" "path/filepath" + "strings" "testing" "time" @@ -866,12 +868,17 @@ func TestPeriodicReconciliationAdoptsFileThatAppearsAfterStartup(t *testing.T) { // 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 -// connection to come free. +// connection to come free, and that the stop logs one warning: the +// interrupted reconciliation's, with no eviction pass started after it. func TestStopEvictionInterruptsPassInProgress(t *testing.T) { t.Parallel() cache, _ := newEvictionTestCache(t, 1<<30) + var logBuf bytes.Buffer + + cache.log = slog.New(slog.NewJSONHandler(&logBuf, nil)) + conn, err := cache.db.Conn(t.Context()) if err != nil { t.Fatalf("failed to take the database connection: %v", err) @@ -902,6 +909,72 @@ func TestStopEvictionInterruptsPassInProgress(t *testing.T) { t.Fatalf("StopEviction() error = %v, want nil: the pass waiting for "+ "the database did not stop", err) } + + t.Logf("log output: %s", logBuf.String()) + + warnings := strings.Count(logBuf.String(), `"level":"WARN"`) + if warnings != 1 { + t.Errorf("the stop logged %d warnings, want 1", warnings) + } +} + +// TestEvictToLimitStopsAtNextCandidateOnceCancelled cancels the context +// while the oldest of three source blobs is being evicted, and checks that +// EvictToLimit then returns context.Canceled without evicting the other +// two or logging a warning for either of them. +func TestEvictToLimitStopsAtNextCandidateOnceCancelled(t *testing.T) { + t.Parallel() + + cache, _ := newEvictionTestCache(t, 1) + + var logBuf bytes.Buffer + + cache.log = slog.New(slog.NewJSONHandler(&logBuf, nil)) + + hashes := []ContentHash{ + storeEvictionTestSource(t, cache, "cancel.example.com", "/a.jpg", + bytes.Repeat([]byte{0x61}, 1000)), + storeEvictionTestSource(t, cache, "cancel.example.com", "/b.jpg", + bytes.Repeat([]byte{0x62}, 1000)), + storeEvictionTestSource(t, cache, "cancel.example.com", "/c.jpg", + bytes.Repeat([]byte{0x63}, 1000)), + } + + base := time.Now().Add(-time.Hour) + + for i, hash := range hashes { + setSourceLastAccessed(t, cache, hash, base.Add(time.Duration(i)*time.Minute)) + } + + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + + cache.evictSourceBlobTestHook = func(ContentHash) { cancel() } + + err := cache.EvictToLimit(ctx) + t.Logf("EvictToLimit() error = %v", err) + t.Logf("log output: %s", logBuf.String()) + + if !errors.Is(err, context.Canceled) { + t.Errorf("EvictToLimit() error = %v, want context.Canceled", err) + } + + if cache.srcContent.Exists(hashes[0]) { + t.Errorf("source blob %s, evicted when the context was cancelled, "+ + "is still on disk", hashes[0]) + } + + for _, hash := range hashes[1:] { + if !cache.srcContent.Exists(hash) { + t.Errorf("source blob %s was evicted after the context was cancelled", hash) + } + } + + if strings.Contains(logBuf.String(), `"level":"WARN"`) { + t.Errorf("EvictToLimit logged a warning after the context was cancelled") + } + + assertNoDanglingReferences(t, cache) } // TestStopEvictionReturnsWhenItsContextEnds pauses an eviction pass where