Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
214 changes: 214 additions & 0 deletions checksum_benchmark_test.go
Original file line number Diff line number Diff line change
@@ -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))
}
145 changes: 137 additions & 8 deletions variables.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"maps"
"os"
"path/filepath"
"reflect"
"strings"

"github.com/joho/godotenv"
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down