Eviction no longer reads a whole table while requests wait on the database (closes #227)
check / check (push) Waiting to run
check / check (push) Waiting to run
UsageBytes now reads the new cache_usage row, which triggers on source_content and variant_content keep up to date in the statement that adds, removes or resizes a row. The reconciliation pass reads both tables 1000 rows per query, sums them, and corrects the total when it differs, unless a row changed while it summed. Source rows now get last_accessed_at when added, so choosing source images to evict reads that column's index instead of sorting the whole table. Stats still sums the tables, now in pages: an existing test drops both tables and expects that sum to fail. Model: opus-5-5
This commit was merged in pull request #228.
This commit is contained in:
@@ -0,0 +1,265 @@
|
||||
package imgcache
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestEvictionReadsTheUsageTotal adds, stores again, resizes and evicts
|
||||
// cache content, then drops both content tables. UsageBytes must still
|
||||
// report what the tables held, and an eviction pass under the limit must
|
||||
// still succeed: neither may sum the tables.
|
||||
func TestEvictionReadsTheUsageTotal(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cache, _ := newEvictionTestCache(t, 1<<30)
|
||||
ctx := t.Context()
|
||||
|
||||
kept := storeEvictionTestSource(t, cache, "total.example.com", "/kept.jpg",
|
||||
bytes.Repeat([]byte{0x71}, 1000))
|
||||
evicted := storeEvictionTestSource(t, cache, "total.example.com", "/evicted.jpg",
|
||||
bytes.Repeat([]byte{0x72}, 700))
|
||||
|
||||
storeEvictionTestVariant(t, cache, testVariantKeyOne,
|
||||
bytes.Repeat([]byte{0x73}, 500))
|
||||
storeEvictionTestVariant(t, cache, testVariantKeyOne,
|
||||
bytes.Repeat([]byte{0x74}, 300))
|
||||
storeEvictionTestVariant(t, cache, testVariantKeyTwo,
|
||||
bytes.Repeat([]byte{0x75}, 200))
|
||||
|
||||
// pixa never changes a source's size, but a statement that does must
|
||||
// change the total too.
|
||||
_, err := cache.db.ExecContext(ctx,
|
||||
`UPDATE source_content SET size_bytes = 900 WHERE content_hash = ?`,
|
||||
string(kept))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to change the source's size: %v", err)
|
||||
}
|
||||
|
||||
err = cache.evictSourceBlob(ctx, evicted)
|
||||
if err != nil {
|
||||
t.Fatalf("evictSourceBlob failed: %v", err)
|
||||
}
|
||||
|
||||
err = cache.evictVariant(ctx, testVariantKeyTwo)
|
||||
if err != nil {
|
||||
t.Fatalf("evictVariant failed: %v", err)
|
||||
}
|
||||
|
||||
_, err = cache.db.ExecContext(ctx,
|
||||
`DROP TABLE source_content; DROP TABLE variant_content`)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to drop the content tables: %v", err)
|
||||
}
|
||||
|
||||
usage, err := cache.UsageBytes(ctx)
|
||||
t.Logf("UsageBytes() = %d, error = %v", usage, err)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("UsageBytes() error = %v, want nil: it read a content table", err)
|
||||
}
|
||||
|
||||
if usage != 1200 {
|
||||
t.Errorf("UsageBytes() = %d, want 1200 (900 + 300)", usage)
|
||||
}
|
||||
|
||||
err = cache.EvictToLimit(ctx)
|
||||
if err != nil {
|
||||
t.Errorf("EvictToLimit() error = %v, want nil: it read a content table", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReconciliationCorrectsTheUsageTotal sets the total cache usage to a
|
||||
// wrong value and checks that a reconciliation pass, which sums both
|
||||
// content tables, puts it right.
|
||||
func TestReconciliationCorrectsTheUsageTotal(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cache, _ := newEvictionTestCache(t, 1<<30)
|
||||
ctx := t.Context()
|
||||
|
||||
storeEvictionTestSource(t, cache, "total.example.com", "/a.jpg",
|
||||
bytes.Repeat([]byte{0x76}, 1000))
|
||||
storeEvictionTestVariant(t, cache, testVariantKeyOne,
|
||||
bytes.Repeat([]byte{0x77}, 500))
|
||||
|
||||
_, err := cache.db.ExecContext(ctx,
|
||||
`UPDATE cache_usage SET total_size_bytes = 1 WHERE id = 1`)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to set a wrong total: %v", err)
|
||||
}
|
||||
|
||||
err = cache.reconcileAccounting(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("reconcileAccounting failed: %v", err)
|
||||
}
|
||||
|
||||
usage, err := cache.UsageBytes(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("UsageBytes failed: %v", err)
|
||||
}
|
||||
|
||||
if usage != 1500 {
|
||||
t.Errorf("UsageBytes() after reconciliation = %d, want 1500 (1000 + 500)",
|
||||
usage)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUsageTotalNotCorrectedFromAnOlderSum stores a variant after the
|
||||
// change count was read, then offers a sum taken before the store as the
|
||||
// correction: the total must keep the stored variant, as that sum may
|
||||
// have missed it.
|
||||
func TestUsageTotalNotCorrectedFromAnOlderSum(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cache, _ := newEvictionTestCache(t, 1<<30)
|
||||
ctx := t.Context()
|
||||
|
||||
var changeCount int64
|
||||
|
||||
err := cache.db.QueryRowContext(ctx,
|
||||
`SELECT change_count FROM cache_usage WHERE id = 1`,
|
||||
).Scan(&changeCount)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to read the change count: %v", err)
|
||||
}
|
||||
|
||||
storeEvictionTestVariant(t, cache, testVariantKeyOne,
|
||||
bytes.Repeat([]byte{0x78}, 500))
|
||||
|
||||
corrected, err := cache.correctUsageTotal(ctx, 0, changeCount)
|
||||
if err != nil {
|
||||
t.Fatalf("correctUsageTotal failed: %v", err)
|
||||
}
|
||||
|
||||
if corrected {
|
||||
t.Error("correctUsageTotal() = true, want false: content changed since the sum")
|
||||
}
|
||||
|
||||
usage, err := cache.UsageBytes(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("UsageBytes failed: %v", err)
|
||||
}
|
||||
|
||||
if usage != 500 {
|
||||
t.Errorf("UsageBytes() = %d, want 500", usage)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReconciliationReadsAPageAtATime stores five source images and five
|
||||
// variants, sets the page size to two rows and runs a reconciliation
|
||||
// pass. No read the pass makes of a content table, to check its rows or
|
||||
// to sum them, may cover more than two rows, so a request's query waits
|
||||
// for one page at most. Between them the reads must still cover every
|
||||
// row of both tables twice, once to check it and once to sum it, and the
|
||||
// sum must put a wrong total right.
|
||||
func TestReconciliationReadsAPageAtATime(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cache, _ := newEvictionTestCache(t, 1<<30)
|
||||
ctx := t.Context()
|
||||
|
||||
const pageSize = 2
|
||||
|
||||
cache.reconciliationPageSize = pageSize
|
||||
|
||||
// Sources of 100 bytes and variants of 10, five of each.
|
||||
for i := range 5 {
|
||||
content := []byte(strconv.Itoa(i))
|
||||
|
||||
storeEvictionTestSource(t, cache, "pages.example.com",
|
||||
"/"+strconv.Itoa(i)+".jpg", bytes.Repeat(content, 100))
|
||||
storeEvictionTestVariant(t, cache, VariantKey("aabbccdd000"+strconv.Itoa(i)),
|
||||
bytes.Repeat(content, 10))
|
||||
}
|
||||
|
||||
_, err := cache.db.ExecContext(ctx,
|
||||
`UPDATE cache_usage SET total_size_bytes = 1 WHERE id = 1`)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to set a wrong total: %v", err)
|
||||
}
|
||||
|
||||
var rowsPerRead []int
|
||||
|
||||
cache.reconciliationReadTestHook = func(rows int) {
|
||||
rowsPerRead = append(rowsPerRead, rows)
|
||||
}
|
||||
|
||||
err = cache.reconcileAccounting(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("reconcileAccounting failed: %v", err)
|
||||
}
|
||||
|
||||
t.Logf("rows covered by each read, in order: %v", rowsPerRead)
|
||||
|
||||
coveredRows := 0
|
||||
|
||||
for _, rows := range rowsPerRead {
|
||||
if rows > pageSize {
|
||||
t.Errorf("a read covered %d rows, want at most %d", rows, pageSize)
|
||||
}
|
||||
|
||||
coveredRows += rows
|
||||
}
|
||||
|
||||
// Ten rows, each read once to check it and once to sum it.
|
||||
if coveredRows != 20 {
|
||||
t.Errorf("the reads covered %d rows in all, want 20", coveredRows)
|
||||
}
|
||||
|
||||
usage, err := cache.UsageBytes(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("UsageBytes failed: %v", err)
|
||||
}
|
||||
|
||||
if usage != 550 {
|
||||
t.Errorf("UsageBytes() after reconciliation = %d, want 550 (5*100 + 5*10)",
|
||||
usage)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSourceEvictionCandidatesComeFromTheIndex checks that the query
|
||||
// choosing source images to evict reads source_content in last access
|
||||
// order from its index, instead of reading and sorting the whole table.
|
||||
func TestSourceEvictionCandidatesComeFromTheIndex(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cache, _ := newEvictionTestCache(t, 1<<30)
|
||||
|
||||
rows, err := cache.db.QueryContext(t.Context(),
|
||||
`EXPLAIN QUERY PLAN `+sourceCandidatesQuery, evictionBatchSize)
|
||||
if err != nil {
|
||||
t.Fatalf("EXPLAIN QUERY PLAN failed: %v", err)
|
||||
}
|
||||
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
var plan []string
|
||||
|
||||
for rows.Next() {
|
||||
var id, parent, unused int
|
||||
|
||||
var detail string
|
||||
|
||||
err := rows.Scan(&id, &parent, &unused, &detail)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to scan the query plan: %v", err)
|
||||
}
|
||||
|
||||
plan = append(plan, detail)
|
||||
}
|
||||
|
||||
err = rows.Err()
|
||||
if err != nil {
|
||||
t.Fatalf("query plan iteration failed: %v", err)
|
||||
}
|
||||
|
||||
t.Logf("query plan: %q", plan)
|
||||
|
||||
if !strings.Contains(strings.Join(plan, "\n"), "idx_source_content_last_accessed") {
|
||||
t.Errorf("the source candidate query does not use "+
|
||||
"idx_source_content_last_accessed; plan: %q", plan)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user