From b2092b188f37312d4b3fabc0ee4ecd3110ecf285 Mon Sep 17 00:00:00 2001 From: Jakob Borg Date: Mon, 27 Jul 2026 12:05:20 +0200 Subject: [PATCH] chore(fs): optimise casefs caching performance (#10830) This tweaks the casefs cache to do in-place updates for modifications we perform, instead of dropping the entire cache. That is, we add, remove and rename things in the cache after having done the corresponding operation. We still drop the cache entirely on larger operations like MkdirAll, and before Walk, and the 1s expiry is unchanged. The result is performance much closer to the original. I set up a device with 150 000 tiny files and measured the time it took for a blank device to come online and sync all the files, with default settings apart from `fsync` being disabled, as it otherwise dominated the benchmark... There are three runs, all are identical in the first 40 seconds which is the index transmission & reception, then we can see the `sensitive` run (casefs disabled) finishes at 137 seconds (137-40 = 97s spent syncing); the `before` run finishes at 238s (198s spent syncing); the `after` run finishes at 142 seconds (102s spent syncing). So, effectively the sync time is cut in half, and is now very close to having case sensitive enabled. Screenshot 2026-07-25 at 23 22 22 --------- Signed-off-by: Jakob Borg --- lib/fs/casefs.go | 148 +++++++++++++++++++++++++++++++++++------------ 1 file changed, 110 insertions(+), 38 deletions(-) diff --git a/lib/fs/casefs.go b/lib/fs/casefs.go index c5d4058dd..af4f2dcfd 100644 --- a/lib/fs/casefs.go +++ b/lib/fs/casefs.go @@ -40,6 +40,8 @@ func IsErrCaseConflict(err error) bool { type realCaser interface { realCase(name string) (string, error) dropCache() + noteAdded(name string) + noteRemoved(name string) } type fskey struct { @@ -154,39 +156,40 @@ type caseFilesystem struct { } func (f *caseFilesystem) Chmod(name string, mode FileMode) error { - if err := f.checkCase(name); err != nil { + if _, err := f.checkCase(name); err != nil { return err } return f.Filesystem.Chmod(name, mode) } func (f *caseFilesystem) Lchown(name, uid, gid string) error { - if err := f.checkCase(name); err != nil { + if _, err := f.checkCase(name); err != nil { return err } return f.Filesystem.Lchown(name, uid, gid) } func (f *caseFilesystem) Chtimes(name string, atime time.Time, mtime time.Time) error { - if err := f.checkCase(name); err != nil { + if _, err := f.checkCase(name); err != nil { return err } return f.Filesystem.Chtimes(name, atime, mtime) } func (f *caseFilesystem) Mkdir(name string, perm FileMode) error { - if err := f.checkCase(name); err != nil { + can, err := f.checkCase(name) + if err != nil { return err } if err := f.Filesystem.Mkdir(name, perm); err != nil { return err } - f.dropCache() + f.noteAdded(can) return nil } func (f *caseFilesystem) MkdirAll(path string, perm FileMode) error { - if err := f.checkCase(path); err != nil { + if _, err := f.checkCase(path); err != nil { return err } if err := f.Filesystem.MkdirAll(path, perm); err != nil { @@ -205,25 +208,26 @@ func (f *caseFilesystem) Lstat(name string) (FileInfo, error) { if err != nil { return nil, err } - if err = f.checkCaseExisting(name); err != nil { + if _, err = f.checkCaseExisting(name); err != nil { return nil, err } return stat, nil } func (f *caseFilesystem) Remove(name string) error { - if err := f.checkCase(name); err != nil { + can, err := f.checkCase(name) + if err != nil { return err } if err := f.Filesystem.Remove(name); err != nil { return err } - f.dropCache() + f.noteRemoved(can) return nil } func (f *caseFilesystem) RemoveAll(name string) error { - if err := f.checkCase(name); err != nil { + if _, err := f.checkCase(name); err != nil { return err } if err := f.Filesystem.RemoveAll(name); err != nil { @@ -234,10 +238,12 @@ func (f *caseFilesystem) RemoveAll(name string) error { } func (f *caseFilesystem) Rename(oldpath, newpath string) error { - if err := f.checkCase(oldpath); err != nil { + oldcan, err := f.checkCase(oldpath) + if err != nil { return err } - if err := f.checkCase(newpath); err != nil { + newcan, err := f.checkCase(newpath) + if err != nil { // Case-only rename is ok e := &CaseConflictError{} if !errors.As(err, &e) || e.Real != oldpath { @@ -247,7 +253,8 @@ func (f *caseFilesystem) Rename(oldpath, newpath string) error { if err := f.Filesystem.Rename(oldpath, newpath); err != nil { return err } - f.dropCache() + f.noteRemoved(oldcan) + f.noteAdded(newcan) return nil } @@ -260,65 +267,68 @@ func (f *caseFilesystem) Stat(name string) (FileInfo, error) { if err != nil { return nil, err } - if err = f.checkCaseExisting(name); err != nil { + if _, err = f.checkCaseExisting(name); err != nil { return nil, err } return stat, nil } func (f *caseFilesystem) DirNames(name string) ([]string, error) { - if err := f.checkCase(name); err != nil { + if _, err := f.checkCase(name); err != nil { return nil, err } return f.Filesystem.DirNames(name) } func (f *caseFilesystem) Open(name string) (File, error) { - if err := f.checkCase(name); err != nil { + if _, err := f.checkCase(name); err != nil { return nil, err } return f.Filesystem.Open(name) } func (f *caseFilesystem) OpenFile(name string, flags int, mode FileMode) (File, error) { - if err := f.checkCase(name); err != nil { + can, err := f.checkCase(name) + if err != nil { return nil, err } file, err := f.Filesystem.OpenFile(name, flags, mode) if err != nil { return nil, err } - f.dropCache() + f.noteAdded(can) return file, nil } func (f *caseFilesystem) ReadSymlink(name string) (string, error) { - if err := f.checkCase(name); err != nil { + if _, err := f.checkCase(name); err != nil { return "", err } return f.Filesystem.ReadSymlink(name) } func (f *caseFilesystem) Create(name string) (File, error) { - if err := f.checkCase(name); err != nil { + can, err := f.checkCase(name) + if err != nil { return nil, err } file, err := f.Filesystem.Create(name) if err != nil { return nil, err } - f.dropCache() + f.noteAdded(can) return file, nil } func (f *caseFilesystem) CreateSymlink(target, name string) error { - if err := f.checkCase(name); err != nil { + can, err := f.checkCase(name) + if err != nil { return err } if err := f.Filesystem.CreateSymlink(target, name); err != nil { return err } - f.dropCache() + f.noteAdded(can) return nil } @@ -326,28 +336,28 @@ func (f *caseFilesystem) Walk(root string, walkFn WalkFunc) error { // Walking the filesystem is likely (in Syncthing's case certainly) done // to pick up external changes, for which caching is undesirable. f.dropCache() - if err := f.checkCase(root); err != nil { + if _, err := f.checkCase(root); err != nil { return err } return f.Filesystem.Walk(root, walkFn) } func (f *caseFilesystem) Watch(path string, ignore Matcher, ctx context.Context, ignorePerms bool) (<-chan Event, <-chan error, error) { - if err := f.checkCase(path); err != nil { + if _, err := f.checkCase(path); err != nil { return nil, nil, err } return f.Filesystem.Watch(path, ignore, ctx, ignorePerms) } func (f *caseFilesystem) Hide(name string) error { - if err := f.checkCase(name); err != nil { + if _, err := f.checkCase(name); err != nil { return err } return f.Filesystem.Hide(name) } func (f *caseFilesystem) Unhide(name string) error { - if err := f.checkCase(name); err != nil { + if _, err := f.checkCase(name); err != nil { return err } return f.Filesystem.Unhide(name) @@ -357,25 +367,25 @@ func (f *caseFilesystem) underlying() (Filesystem, bool) { return f.Filesystem, true } -func (f *caseFilesystem) checkCase(name string) error { +func (f *caseFilesystem) checkCase(name string) (string, error) { var err error if name, err = Canonicalize(name); err != nil { - return err + return "", err } // Stat is necessary for case sensitive FS, as it's then not a conflict // if name is e.g. "foo" and on dir there is "Foo". if _, err := f.Filesystem.Lstat(name); err != nil { if IsNotExist(err) { - return nil + return name, nil } - return err + return "", err } return f.checkCaseExisting(name) } // checkCaseExisting must only be called after successfully canonicalizing and // stating the file. -func (f *caseFilesystem) checkCaseExisting(name string) error { +func (f *caseFilesystem) checkCaseExisting(name string) (string, error) { realName, err := f.realCase(name) if IsNotExist(err) { // It did exist just before -> cache is outdated, try again @@ -383,15 +393,15 @@ func (f *caseFilesystem) checkCaseExisting(name string) error { realName, err = f.realCase(name) } if err != nil { - return err + return "", err } // We normalize the normalization (hah!) of the strings before // comparing, as we don't want to treat a normalization difference as a // case conflict. if norm.NFC.String(realName) != norm.NFC.String(name) { - return &CaseConflictError{name, realName} + return "", &CaseConflictError{name, realName} } - return nil + return realName, nil } type defaultRealCaser struct { @@ -422,6 +432,9 @@ func (r *defaultRealCaser) realCase(name string) (string, error) { return realName, nil } + r.cache.mut.Lock() + defer r.cache.mut.Unlock() + for _, comp := range PathComponents(name) { node := r.cache.getExpireAdd(realName, r.fs) @@ -447,11 +460,70 @@ func (r *defaultRealCaser) dropCache() { r.cache.Purge() } -// getExpireAdd gets an entry for the given key. If no entry exists, or it is -// expired a new one is created and added to the cache. -func (c *caseCache) getExpireAdd(key string, fs Filesystem) *caseNode { +func (r *defaultRealCaser) noteAdded(canonicalName string) { + r.cache.added(canonicalName) +} + +func (r *defaultRealCaser) noteRemoved(canonicalName string) { + r.cache.removed(canonicalName) +} + +// added updates the cached listing of name's parent directory to include +// name, dropping any cached listing for name itself. +func (c *caseCache) added(name string) { c.mut.Lock() defer c.mut.Unlock() + + // Remove any stale entry for the name itself, though it should already + // have been removed by a paired noteRemoved if it existed before. + c.Remove(name) + + // Get and update parent + + node, ok := c.Get(filepath.Dir(name)) + if !ok || node.err != nil { + return + } + + base := filepath.Base(name) + node.children[base] = struct{}{} + node.lowerToReal[UnicodeLowercaseNormalized(base)] = base +} + +// removed updates the cached listing of name's parent directory to no longer +// include name, dropping any cached listing for name itself. +func (c *caseCache) removed(name string) { + c.mut.Lock() + defer c.mut.Unlock() + + // Remove the cache entry for the name itself, if it exists + c.Remove(name) + + // Get the parent and remove it from its set of children + + node, ok := c.Get(filepath.Dir(name)) + if !ok || node.err != nil { + return + } + + if len(node.children) != len(node.lowerToReal) { + // Some entries share a lowercase form, only possible on a + // case-sensitive filesystem. That makes precise incremental removal + // awkward (we'd need to rebuild lowerToReal in sorted order etc), + // so drop the listing and let it be re-read. + c.Remove(filepath.Dir(name)) + return + } + + base := filepath.Base(name) + delete(node.children, base) + delete(node.lowerToReal, UnicodeLowercaseNormalized(base)) +} + +// getExpireAdd gets an entry for the given key. If no entry exists, or it is +// expired a new one is created and added to the cache. The caller must hold +// c.mut. +func (c *caseCache) getExpireAdd(key string, fs Filesystem) *caseNode { node, ok := c.Get(key) if !ok { node := newCaseNode(key, fs)