2 Commits
Author SHA1 Message Date
clawbot 11e3acc76c Go on past cache entries the reconciliation pass cannot read (closes #343)
check / check (push) Canceled after 0s
The walk of each storage directory returned the error of an entry it
could not read. The pass then stopped before its later checks, and the
same entry stopped every later pass. Now reconcileFiles skips a
directory it cannot list and a file whose info it cannot read, temp
files included. It logs a warning naming the path and the error, and the
walk goes on. It reads each file's info once, before the temp-file
check, and hands it to sweepStaleTempFile, reconcileVariantFile or
reconcileSourceFile. A cancelled context still stops the walk with its
error. README.md says so under Storage.

Model: opus-5-5
2026-10-27 00:35:17 +00:00
clawbot 2a61ea262b 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
2026-10-27 00:34:50 +00:00
3 changed files with 85 additions and 33 deletions
+3 -2
View File
@@ -35,8 +35,9 @@ P2: security: per-origin rate limiting
so one such entry stopped every pass before its later checks.
- Each skipped entry gets the warning "skipped unreadable entry in cache
directory", with its path and the error.
- `reconcileFiles` reads each file's info and hands it to
`reconcileVariantFile` and `reconcileSourceFile`.
- `reconcileFiles` reads each file's info once, before the temp-file check,
and hands it to `sweepStaleTempFile`, `reconcileVariantFile` or
`reconcileSourceFile`.
- A cancelled context still stops the walk with its error.
- 2026-10-26 the reconciliation pass drops a variant's accounting row only when
its file is missing (closes #340). Any other error reading the file's size
+10 -15
View File
@@ -542,14 +542,6 @@ func (c *Cache) reconcileFiles(
return nil
}
name := entry.Name()
if strings.HasPrefix(name, tempFilePrefix) {
c.sweepStaleTempFile(path, entry)
return nil
}
info, err := entry.Info()
if err != nil {
c.log.Warn("skipped unreadable entry in cache directory",
@@ -558,6 +550,14 @@ func (c *Cache) reconcileFiles(
return nil
}
name := entry.Name()
if strings.HasPrefix(name, tempFilePrefix) {
c.sweepStaleTempFile(path, info)
return nil
}
return reconcileFile(path, info, name)
},
)
@@ -907,17 +907,12 @@ func (c *Cache) correctUsageTotal(
// sweepStaleTempFile removes a temp file left behind by a crashed
// write once it is old enough that no in-flight store can own it.
func (c *Cache) sweepStaleTempFile(path string, entry fs.DirEntry) {
info, err := entry.Info()
if err != nil {
return
}
func (c *Cache) sweepStaleTempFile(path string, info fs.FileInfo) {
if time.Since(info.ModTime()) < staleTempFileAge {
return
}
err = os.Remove(path)
err := os.Remove(path)
if err != nil && !os.IsNotExist(err) {
c.log.Warn("failed to remove stale temp file", "path", path, "error", err)
+72 -16
View File
@@ -787,15 +787,14 @@ func TestReconcileVariantRowsDropsOnlyRowsOfMissingFiles(t *testing.T) {
}
}
// makeEntriesTooLongToRead makes a chain of directories inside dir, each
// 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.
// 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 {
// 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)
@@ -805,32 +804,64 @@ func makeEntriesTooLongToRead(t *testing.T, dir string) []string {
deepest = filepath.Join(deepest, name)
}
chain, err := filepath.Rel(dir, deepest)
err := os.MkdirAll(deepest, StorageDirPerm)
if err != nil {
t.Fatalf("filepath.Rel() error = %v", err)
t.Fatalf("failed to make a chain of directories: %v", err)
}
root, err := os.OpenRoot(dir)
root, err := os.OpenRoot(deepest)
if err != nil {
t.Fatalf("os.OpenRoot(%s) error = %v", dir, err)
t.Fatalf("os.OpenRoot(%s) error = %v", deepest, err)
}
defer func() { _ = root.Close() }()
t.Cleanup(func() { _ = root.Close() })
err = root.MkdirAll(filepath.Join(chain, name), StorageDirPerm)
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 = root.WriteFile(filepath.Join(chain, fileName), []byte("unreadable"),
StorageFilePerm)
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), filepath.Join(deepest, fileName)}
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
@@ -917,6 +948,31 @@ func TestReconciliationGoesOnPastEntriesItCannotRead(t *testing.T) {
}
}
// TestReconciliationWarnsOfTempFilesItCannotRead 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 TestReconciliationWarnsOfTempFilesItCannotRead(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
// 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