diff --git a/internal/imgcache/eviction_internal_test.go b/internal/imgcache/eviction_internal_test.go index ed7b4eb..a6cdc4d 100644 --- a/internal/imgcache/eviction_internal_test.go +++ b/internal/imgcache/eviction_internal_test.go @@ -9,8 +9,10 @@ import ( "log/slog" "os" "path/filepath" + "slices" "strings" "sync" + "syscall" "testing" "time" @@ -785,6 +787,136 @@ func TestReconcileVariantRowsDropsOnlyRowsOfMissingFiles(t *testing.T) { } } +// makeEntriesTooLongToRead makes a chain of directories inside dir, each +// named with 255 zeros, the longest name Linux allows. Linux refuses a path +// of 4096 bytes or more, and the chain goes as deep as it can below that. +// In its deepest directory it makes a directory and a file whose paths +// reach that length. Even root can then neither list that directory nor +// read the file's info. It returns their two paths. It makes them through +// an os.Root, which names one directory at a time, as no call may name +// the whole path. +func makeEntriesTooLongToRead(t *testing.T, dir string) []string { + t.Helper() + + name := strings.Repeat("0", 255) + deepest := dir + + for len(filepath.Join(deepest, name)) < 4096 { + deepest = filepath.Join(deepest, name) + } + + chain, err := filepath.Rel(dir, deepest) + if err != nil { + t.Fatalf("filepath.Rel() error = %v", err) + } + + root, err := os.OpenRoot(dir) + if err != nil { + t.Fatalf("os.OpenRoot(%s) error = %v", dir, err) + } + + defer func() { _ = root.Close() }() + + err = root.MkdirAll(filepath.Join(chain, name), StorageDirPerm) + if err != nil { + t.Fatalf("failed to make a directory too deep to list: %v", err) + } + + fileName := strings.Repeat("1", 255) + + err = root.WriteFile(filepath.Join(chain, fileName), []byte("unreadable"), + StorageFilePerm) + if err != nil { + t.Fatalf("failed to make a file too deep to read: %v", err) + } + + return []string{filepath.Join(deepest, name), filepath.Join(deepest, fileName)} +} + +// TestReconciliationGoesOnPastEntriesItCannotRead puts, in each storage +// directory, a directory the pass cannot list and a file whose info it +// cannot read. The pass walks each directory in name order, so it reaches +// them before the cache's files, whose keys start with a letter. It must +// log a warning naming each one's path and the error, and still do the +// rest of its work: +// - adopt a variant file that has no row +// - drop the row of a variant whose file is missing +// - remove a source file that has no row +// - correct a wrong total cache usage +func TestReconciliationGoesOnPastEntriesItCannotRead(t *testing.T) { + t.Parallel() + + cache, _ := newEvictionTestCache(t, 1<<30) + ctx := t.Context() + + untrackedVariant := []byte("variant with no row") + + err := cache.variants.Write(string(testVariantKeyOne), untrackedVariant) + if err != nil { + t.Fatalf("failed to write a variant file with no row: %v", err) + } + + storeEvictionTestVariant(t, cache, testVariantKeyTwo, []byte("variant two")) + + err = os.Remove(cache.variants.keyToPath(string(testVariantKeyTwo))) + if err != nil { + t.Fatalf("failed to remove the second variant's file: %v", err) + } + + untrackedSourceKey := "ffeeddcc0001" + + err = cache.srcContent.Write(untrackedSourceKey, []byte("source with no row")) + if err != nil { + t.Fatalf("failed to write a source file with no row: %v", err) + } + + _, 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) + } + + unreadable := slices.Concat( + makeEntriesTooLongToRead(t, cache.variants.baseDir), + makeEntriesTooLongToRead(t, cache.srcContent.baseDir), + ) + + logs := reconcileAsAtStartup(t, cache) + + for _, path := range unreadable { + assertWarningNames(t, logs, path) + + if !strings.Contains(logs, path+": "+syscall.ENAMETOOLONG.Error()) { + t.Errorf("no log line gives the error reading %s", path) + } + } + + rowQuery := `SELECT COUNT(*) FROM variant_content WHERE cache_key = ?` + + if n := countRows(t, cache, rowQuery, string(testVariantKeyOne)); n != 1 { + t.Errorf("the variant file with no row got %d rows, want 1", n) + } + + if n := countRows(t, cache, rowQuery, string(testVariantKeyTwo)); n != 0 { + t.Errorf("the variant with no file kept %d rows, want 0", n) + } + + _, err = os.Stat(cache.srcContent.keyToPath(untrackedSourceKey)) + if err == nil { + t.Error("the source file with no row is still on disk") + } + + usage, err := cache.UsageBytes(ctx) + if err != nil { + t.Fatalf("UsageBytes() error = %v", err) + } + + if usage != int64(len(untrackedVariant)) { + t.Errorf("UsageBytes() = %d, want %d, the size of the adopted variant", + usage, len(untrackedVariant)) + } +} + // TestPeriodicReconciliationAdoptsFileThatAppearsAfterStartup proves // reconciliation is not a one-shot startup-only pass: it must also run // on the periodic ticker, so a variant file that lands on disk with no