Files
syncthing/lib/fs/casefs_test.go
Marcus B Spencer c6a887865f fix(fs): apply case option to fakefs in casefs tests (#10439)
Required for the casefs tests/benchmarks to test the casefs.

Benchmarks do significantly worse (as expected).

```
                                             │ ../old.txt  │             ../new.txt             │
                                             │   sec/op    │   sec/op     vs base               │
WalkCaseFakeFS100k/rawfs-8                     626.5m ± 5%   993.4m ± 1%  +58.56% (p=0.002 n=6)
WalkCaseFakeFS100k/casefs-8                     1.011 ± 1%    1.425 ± 1%  +40.94% (p=0.002 n=6)
WalkCaseFakeFS100k/casefs-otherOpEvery1000-8    1.014 ± 2%    1.439 ± 1%  +41.97% (p=0.002 n=6)
geomean                                        862.9m         1.268       +46.94%

                                             │  ../old.txt  │             ../new.txt              │
                                             │   B/entry    │   B/entry     vs base               │
WalkCaseFakeFS100k/rawfs-8                     1.274Ki ± 0%   1.766Ki ± 0%  +38.54% (p=0.002 n=6)
WalkCaseFakeFS100k/casefs-8                    1.771Ki ± 0%   2.354Ki ± 0%  +32.98% (p=0.002 n=6)
WalkCaseFakeFS100k/casefs-otherOpEvery1000-8   1.772Ki ± 0%   2.356Ki ± 0%  +32.95% (p=0.002 n=6)
geomean                                        1.587Ki        2.140Ki       +34.80%

                                             │   ../old.txt   │               ../new.txt               │
                                             │ DirNames/entry │ DirNames/entry  vs base                │
WalkCaseFakeFS100k/rawfs-8                        512.5m ± 0%     1025.0m ± 0%  +100.00% (p=0.002 n=6)
WalkCaseFakeFS100k/casefs-8                        1.025 ± 0%       1.537 ± 0%   +49.95% (p=0.002 n=6)
WalkCaseFakeFS100k/casefs-otherOpEvery1000-8       1.025 ± 0%       1.537 ± 0%   +49.95% (p=0.002 n=6)
geomean                                           813.5m            1.343        +65.06%

                                             │ ../old.txt  │              ../new.txt              │
                                             │ DirNames/op │ DirNames/op   vs base                │
WalkCaseFakeFS100k/rawfs-8                     51.25k ± 0%   102.49k ± 0%  +100.00% (p=0.002 n=6)
WalkCaseFakeFS100k/casefs-8                    102.5k ± 0%    153.7k ± 0%   +50.00% (p=0.002 n=6)
WalkCaseFakeFS100k/casefs-otherOpEvery1000-8   102.5k ± 0%    153.7k ± 0%   +50.00% (p=0.002 n=6)
geomean                                        81.35k         134.3k        +65.10%

                                             │  ../old.txt  │             ../new.txt              │
                                             │ allocs/entry │ allocs/entry  vs base               │
WalkCaseFakeFS100k/rawfs-8                       19.00 ± 0%     35.35 ± 0%  +86.05% (p=0.002 n=6)
WalkCaseFakeFS100k/casefs-8                      35.35 ± 0%     54.40 ± 0%  +53.89% (p=0.002 n=6)
WalkCaseFakeFS100k/casefs-otherOpEvery1000-8     35.38 ± 0%     54.46 ± 0%  +53.93% (p=0.002 n=6)
geomean                                          28.75          47.14       +63.95%

                                             │ ../old.txt  │             ../new.txt             │
                                             │  sec/entry  │  sec/entry   vs base               │
WalkCaseFakeFS100k/rawfs-8                     4.143µ ± 5%   6.568µ ± 1%  +58.55% (p=0.002 n=6)
WalkCaseFakeFS100k/casefs-8                    6.686µ ± 1%   9.424µ ± 1%  +40.95% (p=0.002 n=6)
WalkCaseFakeFS100k/casefs-otherOpEvery1000-8   6.703µ ± 2%   9.517µ ± 1%  +41.97% (p=0.002 n=6)
geomean                                        5.705µ        8.383µ       +46.94%

                                             │  ../old.txt  │             ../new.txt              │
                                             │     B/op     │     B/op      vs base               │
WalkCaseFakeFS100k/rawfs-8                     188.3Mi ± 0%   260.8Mi ± 0%  +38.51% (p=0.002 n=6)
WalkCaseFakeFS100k/casefs-8                    261.5Mi ± 0%   347.7Mi ± 0%  +32.98% (p=0.002 n=6)
WalkCaseFakeFS100k/casefs-otherOpEvery1000-8   261.7Mi ± 0%   348.0Mi ± 0%  +32.96% (p=0.002 n=6)
geomean                                        234.4Mi        316.0Mi       +34.79%

                                             │ ../old.txt  │             ../new.txt             │
                                             │  allocs/op  │  allocs/op   vs base               │
WalkCaseFakeFS100k/rawfs-8                     2.873M ± 0%   5.346M ± 0%  +86.04% (p=0.002 n=6)
WalkCaseFakeFS100k/casefs-8                    5.346M ± 0%   8.228M ± 0%  +53.91% (p=0.002 n=6)
WalkCaseFakeFS100k/casefs-otherOpEvery1000-8   5.351M ± 0%   8.236M ± 0%  +53.92% (p=0.002 n=6)
geomean                                        4.348M        7.129M       +63.96%
```

Signed-off-by: Marcus B Spencer <marcus@marcusspencer.us>
2025-10-23 08:40:42 +00:00

347 lines
8.7 KiB
Go

// Copyright (C) 2020 The Syncthing Authors.
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this file,
// You can obtain one at https://mozilla.org/MPL/2.0/.
package fs
import (
"errors"
"fmt"
"path/filepath"
"runtime"
"slices"
"strings"
"testing"
"time"
)
func TestRealCase(t *testing.T) {
// Verify realCase lookups on various underlying filesystems.
t.Run("fake-sensitive", func(t *testing.T) {
testRealCase(t, newFakeFilesystem(t.Name()))
})
t.Run("fake-insensitive", func(t *testing.T) {
testRealCase(t, newFakeFilesystem(t.Name()+"?insens=true"))
})
t.Run("actual", func(t *testing.T) {
fsys, _ := setup(t)
testRealCase(t, fsys)
})
}
func newCaseFilesystem(fsys Filesystem) *caseFilesystem {
return globalCaseFilesystemRegistry.get(fsys).(*caseFilesystem)
}
func testRealCase(t *testing.T, fsys Filesystem) {
testFs := newCaseFilesystem(fsys)
comps := []string{"Foo", "bar", "BAZ", "bAs"}
path := filepath.Join(comps...)
testFs.MkdirAll(filepath.Join(comps[:len(comps)-1]...), 0o777)
fd, err := testFs.Create(path)
if err != nil {
t.Fatal(err)
}
fd.Close()
for i, tc := range []struct {
in string
len int
}{
{path, 4},
{strings.ToLower(path), 4},
{strings.ToUpper(path), 4},
{"foo", 1},
{"FOO", 1},
{"foO", 1},
{filepath.Join("Foo", "bar"), 2},
{filepath.Join("Foo", "bAr"), 2},
{filepath.Join("FoO", "bar"), 2},
{filepath.Join("foo", "bar", "BAZ"), 3},
{filepath.Join("Foo", "bar", "bAz"), 3},
{filepath.Join("foo", "bar", "BAZ"), 3}, // Repeat on purpose
} {
out, err := testFs.realCase(tc.in)
if err != nil {
t.Error(err)
} else if exp := filepath.Join(comps[:tc.len]...); out != exp {
t.Errorf("tc %v: Expected %v, got %v", i, exp, out)
}
}
}
func TestRealCaseSensitive(t *testing.T) {
// Verify that realCase returns the best on-disk case for case sensitive
// systems. Test is skipped if the underlying fs is insensitive.
t.Run("fake-sensitive", func(t *testing.T) {
testRealCaseSensitive(t, newFakeFilesystem(t.Name()))
})
t.Run("actual", func(t *testing.T) {
fsys, _ := setup(t)
testRealCaseSensitive(t, fsys)
})
}
func testRealCaseSensitive(t *testing.T, fsys Filesystem) {
testFs := newCaseFilesystem(fsys)
names := make([]string, 2)
names[0] = "foo"
names[1] = strings.ToUpper(names[0])
for _, n := range names {
if err := testFs.MkdirAll(n, 0o777); err != nil {
if IsErrCaseConflict(err) {
t.Skip("Filesystem is case-insensitive")
}
t.Fatal(err)
}
}
for _, n := range names {
if rn, err := testFs.realCase(n); err != nil {
t.Error(err)
} else if rn != n {
t.Errorf("Got %v, expected %v", rn, n)
}
}
}
func TestCaseFSStat(t *testing.T) {
// Verify that a Stat() lookup behaves in a case sensitive manner
// regardless of the underlying fs.
t.Run("fake-sensitive", func(t *testing.T) {
testCaseFSStat(t, newFakeFilesystem(t.Name()))
})
t.Run("fake-insensitive", func(t *testing.T) {
testCaseFSStat(t, newFakeFilesystem(t.Name()+"?insens=true"))
})
t.Run("actual", func(t *testing.T) {
fsys, _ := setup(t)
testCaseFSStat(t, fsys)
})
}
func testCaseFSStat(t *testing.T, fsys Filesystem) {
fd, err := fsys.Create("foo")
if err != nil {
t.Fatal(err)
}
fd.Close()
// Check if the underlying fs is sensitive or not
sensitive := true
if _, err = fsys.Stat("FOO"); err == nil {
sensitive = false
}
testFs := newCaseFilesystem(fsys)
_, err = testFs.Stat("FOO")
if sensitive {
if IsNotExist(err) {
t.Log("pass: case sensitive underlying fs")
} else {
t.Error("expected NotExist, not", err, "for sensitive fs")
}
} else if IsErrCaseConflict(err) {
t.Log("pass: case insensitive underlying fs")
} else {
t.Error("expected ErrCaseConflict, not", err, "for insensitive fs")
}
}
func BenchmarkWalkCaseFakeFS100k(b *testing.B) {
const entries = 100_000
fsys, paths, err := fakefsForBenchmark(entries, 0)
if err != nil {
b.Fatal(err)
}
b.Run("rawfs", func(b *testing.B) {
fakefs, ok := unwrapFilesystem[*fakeFS](fsys)
if !ok {
panic("expected unwrap to fakefs")
}
fakefs.resetCounters()
benchmarkWalkFakeFS(b, fsys, paths, 0, "")
fakefs.reportMetricsPerOp(b)
fakefs.reportMetricsPer(b, entries, "entry")
b.ReportAllocs()
})
b.Run("casefs", func(b *testing.B) {
// Construct the casefs manually or it will get cached and the benchmark is invalid.
casefs := &caseFilesystem{
Filesystem: fsys,
realCaser: &defaultRealCaser{
fs: fsys,
cache: newCaseCache(),
},
}
fakefs, ok := unwrapFilesystem[*fakeFS](fsys)
if !ok {
panic("expected unwrap to fakefs")
}
fakefs.resetCounters()
benchmarkWalkFakeFS(b, casefs, paths, 0, "")
fakefs.reportMetricsPerOp(b)
fakefs.reportMetricsPer(b, entries, "entry")
b.ReportAllocs()
})
var otherOpPath string
sep := string(PathSeparator)
longest := 0
for _, p := range paths {
if length := len(strings.Split(p, sep)); length > longest {
otherOpPath = p
longest = length
}
}
otherOpEvery := 1000
b.Run(fmt.Sprintf("casefs-otherOpEvery%v", otherOpEvery), func(b *testing.B) {
// Construct the casefs manually or it will get cached and the benchmark is invalid.
casefs := &caseFilesystem{
Filesystem: fsys,
realCaser: &defaultRealCaser{
fs: fsys,
cache: newCaseCache(),
},
}
fakefs, ok := unwrapFilesystem[*fakeFS](fsys)
if !ok {
panic("expected unwrap to fakefs")
}
fakefs.resetCounters()
benchmarkWalkFakeFS(b, casefs, paths, otherOpEvery, otherOpPath)
fakefs.reportMetricsPerOp(b)
fakefs.reportMetricsPer(b, entries, "entry")
b.ReportAllocs()
})
}
func benchmarkWalkFakeFS(b *testing.B, fsys Filesystem, paths []string, otherOpEvery int, otherOpPath string) {
// Simulate a scanner pass over the filesystem. First walk it to
// discover all names, then stat each name individually to check if it's
// been deleted or not (pretending that they all existed in the
// database).
var ms0 runtime.MemStats
runtime.ReadMemStats(&ms0)
t0 := time.Now()
for i := 0; i < b.N; i++ {
if err := doubleWalkFSWithOtherOps(fsys, paths, otherOpEvery, otherOpPath); err != nil {
b.Fatal(err)
}
}
t1 := time.Now()
var ms1 runtime.MemStats
runtime.ReadMemStats(&ms1)
// We add metrics per path entry
b.ReportMetric(float64(t1.Sub(t0))/float64(b.N)/float64(len(paths)), "ns/entry")
b.ReportMetric(float64(ms1.Mallocs-ms0.Mallocs)/float64(b.N)/float64(len(paths)), "allocs/entry")
b.ReportMetric(float64(ms1.TotalAlloc-ms0.TotalAlloc)/float64(b.N)/float64(len(paths)), "B/entry")
}
func TestStressCaseFS(t *testing.T) {
// Exercise a bunch of parallel operations for stressing out race
// conditions in the realnamer cache etc.
const limit = 10 * time.Second
if testing.Short() {
t.Skip("long test")
}
fsys, paths, err := fakefsForBenchmark(10_000, 0)
if err != nil {
t.Fatal(err)
}
for i := 0; i < runtime.NumCPU()/2+1; i++ {
t.Run(fmt.Sprintf("walker-%d", i), func(t *testing.T) {
// Walk the filesystem and stat everything
t.Parallel()
t0 := time.Now()
for time.Since(t0) < limit {
if err := doubleWalkFS(fsys, paths); err != nil {
t.Fatal(err)
}
}
})
t.Run(fmt.Sprintf("toucher-%d", i), func(t *testing.T) {
// Touch all the things
t.Parallel()
t0 := time.Now()
for time.Since(t0) < limit {
for _, p := range paths {
now := time.Now()
if err := fsys.Chtimes(p, now, now); err != nil {
t.Fatal(err)
}
}
}
})
}
}
func doubleWalkFS(fsys Filesystem, paths []string) error {
return doubleWalkFSWithOtherOps(fsys, paths, 0, "")
}
func doubleWalkFSWithOtherOps(fsys Filesystem, paths []string, otherOpEvery int, otherOpPath string) error {
i := 0
if err := fsys.Walk("/", func(path string, info FileInfo, err error) error {
i++
if otherOpEvery != 0 && i%otherOpEvery == 0 {
if _, err := fsys.Lstat(otherOpPath); err != nil {
return err
}
}
return err
}); err != nil {
return err
}
for _, p := range paths {
for p != "." {
i++
if otherOpEvery != 0 && i%otherOpEvery == 0 {
if _, err := fsys.Lstat(otherOpPath); err != nil {
return err
}
}
if _, err := fsys.Lstat(p); err != nil {
return err
}
p = filepath.Dir(p)
}
}
return nil
}
func fakefsForBenchmark(nfiles int, latency time.Duration) (Filesystem, []string, error) {
fsys := NewFilesystem(FilesystemTypeFake, fmt.Sprintf("fakefsForBenchmark?files=%d&insens=true&latency=%s", nfiles, latency), &OptionDetectCaseConflicts{})
var paths []string
if err := fsys.Walk("/", func(path string, info FileInfo, err error) error {
paths = append(paths, path)
return err
}); err != nil {
return nil, nil, err
}
if len(paths) < nfiles {
return nil, nil, errors.New("didn't find enough stuff")
}
slices.Sort(paths)
return fsys, paths, nil
}