package imgcache import ( "bytes" "fmt" "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 adds one row more than a page to // each content table, none of them with a file. Each reconciliation read // must return one page at most, so a request's query waits for one page // at most, and the pass must still reach every row: it sums all of them // and drops all of them. func TestReconciliationReadsAPageAtATime(t *testing.T) { t.Parallel() cache, _ := newEvictionTestCache(t, 1<<30) ctx := t.Context() const rowsPerTable = reconciliationPageSize + 1 for i := range rowsPerTable { _, err := cache.db.ExecContext(ctx, ` INSERT INTO variant_content (cache_key, size_bytes, content_type) VALUES (?, 1, ?) `, fmt.Sprintf("%012x", i), testContentTypeWebP) if err != nil { t.Fatalf("failed to insert variant row %d: %v", i, err) } _, err = cache.db.ExecContext(ctx, ` INSERT INTO source_content (content_hash, content_type, size_bytes) VALUES (?, ?, 1) `, fmt.Sprintf("%064x", i), testContentTypeJPEG) if err != nil { t.Fatalf("failed to insert source row %d: %v", i, err) } } keys, err := cache.variantKeysAfter(ctx, "") if err != nil { t.Fatalf("variantKeysAfter failed: %v", err) } if len(keys) != reconciliationPageSize { t.Errorf("one read returned %d variant keys, want %d", len(keys), reconciliationPageSize) } hashes, err := cache.sourceContentHashesAfter(ctx, "") if err != nil { t.Fatalf("sourceContentHashesAfter failed: %v", err) } if len(hashes) != reconciliationPageSize { t.Errorf("one read returned %d source hashes, want %d", len(hashes), reconciliationPageSize) } sum, err := cache.sumContentSizeBytes(ctx) if err != nil { t.Fatalf("sumContentSizeBytes failed: %v", err) } if sum != 2*rowsPerTable { t.Errorf("sumContentSizeBytes() = %d, want %d", sum, 2*rowsPerTable) } err = cache.reconcileAccounting(ctx) if err != nil { t.Fatalf("reconcileAccounting failed: %v", err) } if n := countRows(t, cache, `SELECT COUNT(*) FROM variant_content`); n != 0 { t.Errorf("%d variant rows without a file are left, want 0", n) } if n := countRows(t, cache, `SELECT COUNT(*) FROM source_content`); n != 0 { t.Errorf("%d source rows without a file are left, want 0", n) } } // 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) } }