diff --git a/checksum_benchmark_test.go b/checksum_benchmark_test.go new file mode 100644 index 0000000000..7677f7fa32 --- /dev/null +++ b/checksum_benchmark_test.go @@ -0,0 +1,214 @@ +//go:build fsbench +// +build fsbench + +package task_test + +import ( + "bytes" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/go-task/task/v3" +) + +const ( + manySmallFileCount = 20_000 + smallFileSize = 5 + fewLargeFileCount = 4 + largeFileSize = 128 * 1024 * 1024 +) + +func BenchmarkManySmallFiles(b *testing.B) { + dir := b.TempDir() + createBenchmarkFixture(b, dir, manySmallFileCount, smallFileSize) + + benchmarkModes(b, dir, manySmallFileCount, smallFileSize) +} + +func BenchmarkFewLargeFiles(b *testing.B) { + dir := b.TempDir() + createBenchmarkFixture(b, dir, fewLargeFileCount, largeFileSize) + + benchmarkModes(b, dir, fewLargeFileCount, largeFileSize) +} + +func benchmarkModes(b *testing.B, dir string, fileCount int, fileSize int64) { + b.Helper() + + for _, mode := range []struct { + name string + task string + expectCache bool + nativeMTime bool + }{ + {name: "checksum", task: "checksum-yaml", expectCache: true}, + {name: "timestamp", task: "timestamp-yaml", expectCache: true}, + {name: "native-mtime", nativeMTime: true}, + {name: "none", task: "uncached-yaml"}, + } { + b.Run(mode.name, func(b *testing.B) { + if mode.nativeMTime { + benchmarkNativeMTime(b, dir, fileCount, fileSize) + return + } + benchmarkTask(b, dir, mode.task, mode.expectCache, fileCount, fileSize) + }) + } +} + +func benchmarkTask( + b *testing.B, + dir string, + taskName string, + expectCache bool, + fileCount int, + fileSize int64, +) { + b.Helper() + + tempDir := task.TempDir{ + Remote: filepath.Join(dir, ".task"), + Fingerprint: filepath.Join(dir, ".task"), + } + + if expectCache { + e := task.NewExecutor( + task.WithDir(dir), + task.WithStdout(io.Discard), + task.WithStderr(io.Discard), + task.WithTempDir(tempDir), + ) + require.NoError(b, e.Setup()) + require.NoError(b, e.Run(b.Context(), &task.Call{Task: taskName})) + } + + b.ReportAllocs() + sourceBytes := int64(fileCount) * fileSize + if expectCache { + b.SetBytes(sourceBytes) + } + b.ResetTimer() + for range b.N { + var buff bytes.Buffer + e := task.NewExecutor( + task.WithDir(dir), + task.WithStdout(&buff), + task.WithStderr(&buff), + task.WithTempDir(tempDir), + ) + require.NoError(b, e.Setup()) + require.NoError(b, e.Run(b.Context(), &task.Call{Task: taskName})) + if expectCache { + require.Contains(b, buff.String(), fmt.Sprintf(`Task "%s" is up to date`, taskName)) + } + } + if expectCache { + b.ReportMetric(float64(fileCount), "source_files/op") + b.ReportMetric(float64(sourceBytes)/(1024*1024), "source_MiB/op") + } +} + +func benchmarkNativeMTime(b *testing.B, dir string, fileCount int, fileSize int64) { + b.Helper() + + output := filepath.Join(dir, "out", "native-mtime.txt") + require.NoError(b, os.WriteFile(output, []byte("ok"), 0o644)) + outputTime := time.Now().Add(time.Second) + require.NoError(b, os.Chtimes(output, outputTime, outputTime)) + + sourceRoot := filepath.Join(dir, "path", "to", "folder") + sourceBytes := int64(fileCount) * fileSize + + b.ReportAllocs() + b.SetBytes(sourceBytes) + b.ResetTimer() + for range b.N { + outputInfo, err := os.Stat(output) + require.NoError(b, err) + + upToDate, err := nativeMTimeUpToDate(sourceRoot, outputInfo.ModTime()) + require.NoError(b, err) + require.True(b, upToDate) + } + b.ReportMetric(float64(fileCount), "source_files/op") + b.ReportMetric(float64(sourceBytes)/(1024*1024), "source_MiB/op") +} + +func nativeMTimeUpToDate(sourceRoot string, outputTime time.Time) (bool, error) { + upToDate := true + err := filepath.WalkDir(sourceRoot, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() || filepath.Ext(path) != ".yaml" { + return nil + } + info, err := d.Info() + if err != nil { + return err + } + if info.ModTime().After(outputTime) { + upToDate = false + return fs.SkipAll + } + return nil + }) + return upToDate, err +} + +func createBenchmarkFixture(tb testing.TB, dir string, fileCount int, fileSize int64) { + tb.Helper() + + taskfile := `version: '3' + +tasks: + checksum-yaml: + sources: + - path/to/folder/**/*.yaml + generates: + - out/checksum.txt + cmds: + - printf ok > out/checksum.txt + + timestamp-yaml: + method: timestamp + sources: + - path/to/folder/**/*.yaml + generates: + - out/timestamp.txt + cmds: + - printf ok > out/timestamp.txt + + uncached-yaml: + method: none + cmds: + - printf ok > out/uncached.txt +` + require.NoError(tb, os.WriteFile(filepath.Join(dir, "Taskfile.yml"), []byte(taskfile), 0o644)) + require.NoError(tb, os.MkdirAll(filepath.Join(dir, "out"), 0o755)) + + for i := 1; i <= fileCount; i++ { + subdir := filepath.Join(dir, "path", "to", "folder", fmt.Sprintf("%04d", i/100)) + require.NoError(tb, os.MkdirAll(subdir, 0o755)) + name := filepath.Join(subdir, fmt.Sprintf("file-%05d.yaml", i)) + createSparseFile(tb, name, fileSize) + } +} + +func createSparseFile(tb testing.TB, name string, size int64) { + tb.Helper() + + file, err := os.OpenFile(name, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644) + require.NoError(tb, err) + defer func() { + require.NoError(tb, file.Close()) + }() + require.NoError(tb, file.Truncate(size)) +} diff --git a/variables.go b/variables.go index c7c6cc8493..9d300d2a9b 100644 --- a/variables.go +++ b/variables.go @@ -5,6 +5,7 @@ import ( "maps" "os" "path/filepath" + "reflect" "strings" "github.com/joho/godotenv" @@ -201,15 +202,17 @@ func (e *Executor) compiledTask(call *Call, evaluateShVars bool) (*ast.Task, err checker = fingerprint.NewChecksumChecker(e.TempDir.Fingerprint, e.Dry) } - value, err := checker.Value(&new) - if err != nil { - return nil, err - } - vars.Set(strings.ToUpper(checker.Kind()), ast.Var{Live: value}) + if taskReferencesFingerprintVar(origTask, checker.Kind()) { + value, err := checker.Value(&new) + if err != nil { + return nil, err + } + vars.Set(strings.ToUpper(checker.Kind()), ast.Var{Live: value}) - // Adding new variables, requires us to refresh the templaters - // cache of the the values manually - cache.ResetCache() + // Adding new variables, requires us to refresh the templaters + // cache of the the values manually + cache.ResetCache() + } } if len(origTask.Cmds) > 0 { @@ -326,6 +329,132 @@ func (e *Executor) compiledTask(call *Call, evaluateShVars bool) (*ast.Task, err return &new, nil } +func taskReferencesFingerprintVar(t *ast.Task, kind string) bool { + name := strings.ToUpper(kind) + + for _, status := range t.Status { + if stringReferencesFingerprintVar(status, name) { + return true + } + } + for _, cmd := range t.Cmds { + if cmdReferencesFingerprintVar(cmd, name) { + return true + } + } + for _, dep := range t.Deps { + if depReferencesFingerprintVar(dep, name) { + return true + } + } + for _, precondition := range t.Preconditions { + if preconditionReferencesFingerprintVar(precondition, name) { + return true + } + } + return false +} + +func cmdReferencesFingerprintVar(cmd *ast.Cmd, name string) bool { + if cmd == nil { + return false + } + return stringReferencesFingerprintVar(cmd.Cmd, name) || + stringReferencesFingerprintVar(cmd.Task, name) || + stringReferencesFingerprintVar(cmd.If, name) || + forReferencesFingerprintVar(cmd.For, name) || + varsReferenceFingerprintVar(cmd.Vars, name) +} + +func depReferencesFingerprintVar(dep *ast.Dep, name string) bool { + if dep == nil { + return false + } + return stringReferencesFingerprintVar(dep.Task, name) || + forReferencesFingerprintVar(dep.For, name) || + varsReferenceFingerprintVar(dep.Vars, name) +} + +func preconditionReferencesFingerprintVar(precondition *ast.Precondition, name string) bool { + if precondition == nil { + return false + } + return stringReferencesFingerprintVar(precondition.Sh, name) || + stringReferencesFingerprintVar(precondition.Msg, name) +} + +func forReferencesFingerprintVar(f *ast.For, name string) bool { + if f == nil { + return false + } + if valueReferencesFingerprintVar(f.List, name) { + return true + } + for _, row := range f.Matrix.All() { + if stringReferencesFingerprintVar(row.Ref, name) || + valueReferencesFingerprintVar(row.Value, name) { + return true + } + } + return false +} + +func varsReferenceFingerprintVar(vars *ast.Vars, name string) bool { + if vars == nil { + return false + } + for _, v := range vars.All() { + if valueReferencesFingerprintVar(v.Value, name) || + valueReferencesFingerprintVar(v.Live, name) || + stringPointerReferencesFingerprintVar(v.Sh, name) || + stringReferencesFingerprintVar(v.Ref, name) || + stringReferencesFingerprintVar(v.Dir, name) { + return true + } + } + return false +} + +func valueReferencesFingerprintVar(value any, name string) bool { + if value == nil { + return false + } + if s, ok := value.(string); ok { + return stringReferencesFingerprintVar(s, name) + } + + rv := reflect.ValueOf(value) + switch rv.Kind() { + case reflect.Pointer, reflect.Interface: + if rv.IsNil() { + return false + } + return valueReferencesFingerprintVar(rv.Elem().Interface(), name) + case reflect.Array, reflect.Slice: + for i := 0; i < rv.Len(); i++ { + if valueReferencesFingerprintVar(rv.Index(i).Interface(), name) { + return true + } + } + case reflect.Map: + for _, key := range rv.MapKeys() { + if valueReferencesFingerprintVar(key.Interface(), name) || + valueReferencesFingerprintVar(rv.MapIndex(key).Interface(), name) { + return true + } + } + } + return false +} + +func stringPointerReferencesFingerprintVar(s *string, name string) bool { + return s != nil && stringReferencesFingerprintVar(*s, name) +} + +func stringReferencesFingerprintVar(s string, name string) bool { + return strings.Contains(s, name) +} + func asAnySlice[T any](slice []T) []any { ret := make([]any, len(slice)) for i, v := range slice {