Test that reconciliation goes on past entries it cannot read
Each storage directory gets a directory the pass cannot list and a file whose info it cannot read. Their paths are longer than Linux accepts, even for root. The test wants a warning naming each path and the error. It also wants the rest of the pass done: a variant file with no row adopted, the row of a missing variant file dropped, a source file with no row removed, and a wrong usage total corrected. A second test puts a temp file whose info cannot be read in each storage directory and wants the same warning. On next the first test's pass stops at the first such directory and returns its error. The second test gets no warning. Model: opus-5-5
This commit is contained in:
@@ -9,8 +9,10 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"syscall"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -785,6 +787,192 @@ func TestReconcileVariantRowsDropsOnlyRowsOfMissingFiles(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// openDirectoryNearPathLimit 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.
|
||||||
|
// It returns the chain's deepest directory as an os.Root, closed when the
|
||||||
|
// test ends. An entry made through it with a 255-byte name has a path even
|
||||||
|
// the root user cannot read. The os.Root can make that entry because it
|
||||||
|
// names the entry only by its name, not by the whole path.
|
||||||
|
func openDirectoryNearPathLimit(t *testing.T, dir string) *os.Root {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
name := strings.Repeat("0", 255)
|
||||||
|
deepest := dir
|
||||||
|
|
||||||
|
for len(filepath.Join(deepest, name)) < 4096 {
|
||||||
|
deepest = filepath.Join(deepest, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
err := os.MkdirAll(deepest, StorageDirPerm)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to make a chain of directories: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
root, err := os.OpenRoot(deepest)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("os.OpenRoot(%s) error = %v", deepest, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Cleanup(func() { _ = root.Close() })
|
||||||
|
|
||||||
|
return root
|
||||||
|
}
|
||||||
|
|
||||||
|
// makeEntriesTooLongToRead makes a directory and a file in the directory
|
||||||
|
// that openDirectoryNearPathLimit opens inside dir. Even the root user can
|
||||||
|
// then neither list that directory nor read the file's info. It returns
|
||||||
|
// their two paths.
|
||||||
|
func makeEntriesTooLongToRead(t *testing.T, dir string) []string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
deepest := openDirectoryNearPathLimit(t, dir)
|
||||||
|
dirName := strings.Repeat("0", 255)
|
||||||
|
|
||||||
|
err := deepest.Mkdir(dirName, StorageDirPerm)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to make a directory too deep to list: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fileName := strings.Repeat("1", 255)
|
||||||
|
|
||||||
|
err = deepest.WriteFile(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(), dirName),
|
||||||
|
filepath.Join(deepest.Name(), fileName),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// makeTempFileTooLongToRead makes a temp file in the directory that
|
||||||
|
// openDirectoryNearPathLimit opens inside dir. Even the root user then
|
||||||
|
// cannot read its info. It returns its path.
|
||||||
|
func makeTempFileTooLongToRead(t *testing.T, dir string) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
deepest := openDirectoryNearPathLimit(t, dir)
|
||||||
|
name := tempFilePrefix + strings.Repeat("1", 255-len(tempFilePrefix))
|
||||||
|
|
||||||
|
err := deepest.WriteFile(name, []byte("partial"), StorageFilePerm)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to make a temp file too deep to read: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return filepath.Join(deepest.Name(), name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestReconciliationWarnsOfTempFilesWhoseInfoItCannotRead puts, in each
|
||||||
|
// storage directory, a temp file whose info the pass cannot read. The
|
||||||
|
// pass must log a warning naming each one's path and the error, as it
|
||||||
|
// does for any other file whose info it cannot read.
|
||||||
|
func TestReconciliationWarnsOfTempFilesWhoseInfoItCannotRead(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cache, _ := newEvictionTestCache(t, 1<<30)
|
||||||
|
|
||||||
|
unreadable := []string{
|
||||||
|
makeTempFileTooLongToRead(t, cache.variants.baseDir),
|
||||||
|
makeTempFileTooLongToRead(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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestPeriodicReconciliationAdoptsFileThatAppearsAfterStartup proves
|
// TestPeriodicReconciliationAdoptsFileThatAppearsAfterStartup proves
|
||||||
// reconciliation is not a one-shot startup-only pass: it must also run
|
// 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
|
// on the periodic ticker, so a variant file that lands on disk with no
|
||||||
|
|||||||
Reference in New Issue
Block a user