Test that eviction ticks read nothing for reconciliation

The eviction loop runs the reconciliation pass on every periodic tick,
so this test fails until the pass runs at startup and then once a day.

Model: opus-5-5
This commit is contained in:
2026-10-15 20:42:35 +00:00
parent 4e30dc7467
commit af6ab4a668
@@ -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