go.fuchsia.dev/infra@v0.0.0-20240507153436-9b593402251b/cmd/update_test_durations/update.go (about)

     1  // Copyright 2022 The Fuchsia Authors. All rights reserved.
     2  // Use of this source code is governed by a BSD-style license that can be
     3  // found in the LICENSE file.
     4  
     5  // This tool queries ResultDB data from BigQuery to retrieve statistics about to
     6  // the durations of Fuchsia tests. The resulting data will be uploaded to CIPD
     7  // for use by the testsharder tool:
     8  // https://fuchsia.googlesource.com/fuchsia/+/HEAD/tools/integration/testsharder
     9  
    10  package main
    11  
    12  import (
    13  	"context"
    14  	"encoding/json"
    15  	"errors"
    16  	"fmt"
    17  	"math"
    18  	"os"
    19  	"path/filepath"
    20  	"sort"
    21  	"strings"
    22  
    23  	"github.com/maruel/subcommands"
    24  	"go.chromium.org/luci/auth"
    25  	"go.fuchsia.dev/infra/functools"
    26  	"golang.org/x/exp/maps"
    27  )
    28  
    29  var errZeroTotalRuns = errors.New("cannot calculate average duration for tests with zero total runs")
    30  
    31  const (
    32  	// For each set of duration files (public and internal), the statistics for
    33  	// all tests will be aggregated to create a new file containing each test's
    34  	// average duration across all builders. This file will be used by any
    35  	// builders that don't yet have a corresponding duration file.
    36  	defaultBuilderName = "default"
    37  
    38  	// Every duration file will have a default entry that's applied to any test
    39  	// that doesn't yet have an entry (probably because it is new or renamed). It
    40  	// will have this value in its "name" field. We chose "*" to strike a balance
    41  	// between distinguishing this entry from actual tests (text "default" would
    42  	// be easier to mistake for an actual test) and being obviously intentional
    43  	// and clear about its purpose (an empty string would less clearly be a
    44  	// fallback, and might even suggest a bug in the updater).
    45  	defaultTestName = "*"
    46  )
    47  
    48  func cmdRun(authOpts auth.Options) *subcommands.Command {
    49  	return &subcommands.Command{
    50  		UsageLine: "run -dir DIR -project PROJECT",
    51  		ShortDesc: "write updated test duration data to a directory",
    52  		LongDesc:  "write updated test duration data to a directory",
    53  		CommandRun: func() subcommands.CommandRun {
    54  			c := &runCmd{}
    55  			c.Init(authOpts)
    56  			return c
    57  		},
    58  	}
    59  }
    60  
    61  type runCmd struct {
    62  	commonFlags
    63  
    64  	previousVersionDir string
    65  	outputDir          string
    66  	luciProject        string
    67  	dataWindowDays     int
    68  }
    69  
    70  // test is a pairing of a test and builder, along with the number of recorded
    71  // runs and median duration within the time window. The order of the fields here
    72  // must be kept in sync with the order of the rows returned by the BigQuery
    73  // query.
    74  type test struct {
    75  	Name string `json:"name"`
    76  
    77  	// The number of test runs included in calculating the duration.
    78  	Runs int64 `json:"runs"`
    79  
    80  	// The median duration of the test, in milliseconds, across all included runs.
    81  	MedianDurationMS int64 `json:"median_duration_ms"`
    82  
    83  	// The builder that ran this test. Test durations are separated into files
    84  	// by builder, so it's not necessary to include the builder name in the
    85  	// output JSON.
    86  	Builder string `json:"-"`
    87  }
    88  
    89  // testDurationsMap maps from the name of a builder to a list of tests that were
    90  // run by that builder. Each entry in the map corresponds to one file in the
    91  // resulting test duration files.
    92  type testDurationMap map[string][]test
    93  
    94  // durationFileOptions contains feature flags for splitTestsByBuilder. This is
    95  // primarily for testing purposes; turning off some features makes it easier to
    96  // construct expected data for tests where we're making assertions that aren't
    97  // related to those features.
    98  //
    99  // All features should always be enabled in production.
   100  type durationFileOptions struct {
   101  	includeDefaultTests   bool
   102  	includeDefaultBuilder bool
   103  }
   104  
   105  func (c *runCmd) Init(defaultAuthOpts auth.Options) {
   106  	c.commonFlags.Init(defaultAuthOpts)
   107  	c.Flags.StringVar(
   108  		&c.previousVersionDir,
   109  		"previous-version-dir",
   110  		"",
   111  		"Directory containing the previous version of the durations files, which "+
   112  			"will be merged with the updated durations.")
   113  	c.Flags.StringVar(
   114  		&c.outputDir,
   115  		"output-dir",
   116  		"",
   117  		"Directory into which final duration files should be written.")
   118  	c.Flags.StringVar(
   119  		&c.luciProject,
   120  		"project",
   121  		"fuchsia",
   122  		"LUCI project to query test durations for.")
   123  	c.Flags.IntVar(
   124  		&c.dataWindowDays,
   125  		"days",
   126  		3,
   127  		"LUCI project to query test durations for.")
   128  }
   129  
   130  func (c *runCmd) parseArgs([]string) error {
   131  	return c.commonFlags.Parse()
   132  }
   133  
   134  func (c *runCmd) Run(a subcommands.Application, args []string, _ subcommands.Env) int {
   135  	if err := c.parseArgs(args); err != nil {
   136  		fmt.Fprintf(a.GetErr(), "%s: %s\n", a.GetName(), err)
   137  		return 1
   138  	}
   139  
   140  	if err := c.main(context.Background()); err != nil {
   141  		fmt.Fprintf(a.GetErr(), "%s: %s\n", a.GetName(), err)
   142  		return 1
   143  	}
   144  	return 0
   145  }
   146  
   147  // updateTestDurations fetches the latest test durations from BigQuery and
   148  // uploads them to CIPD.
   149  func (c *runCmd) main(ctx context.Context) error {
   150  	if c.previousVersionDir == "" {
   151  		return errors.New("flag -previous-version-dir is required")
   152  	}
   153  	if c.outputDir == "" {
   154  		return errors.New("flag -output-dir is required")
   155  	}
   156  	fetchCtx, cancel := context.WithTimeout(ctx, queryTimeout)
   157  	defer cancel()
   158  	rows, err := queryLatestTestDurations(fetchCtx, c.luciProject, c.dataWindowDays)
   159  	if err != nil {
   160  		return err
   161  	}
   162  
   163  	durations, err := splitTestsByBuilder(rows, durationFileOptions{
   164  		includeDefaultTests:   true,
   165  		includeDefaultBuilder: true,
   166  	})
   167  	if err != nil {
   168  		return err
   169  	}
   170  
   171  	newFileContents, err := marshalDurations(durations)
   172  	if err != nil {
   173  		return err
   174  	}
   175  
   176  	oldFileContents, err := previousVersionContents(c.previousVersionDir)
   177  	if err != nil {
   178  		return err
   179  	}
   180  
   181  	finalContents := overlayFileContents(oldFileContents, newFileContents)
   182  	for name, contents := range finalContents {
   183  		path := filepath.Join(c.outputDir, name)
   184  		if err := os.WriteFile(path, contents, 0o600); err != nil {
   185  			return fmt.Errorf("failed to write: %w", err)
   186  		}
   187  	}
   188  
   189  	return nil
   190  }
   191  
   192  // splitTestsByBuilder takes a collection of tests and arranges them into a
   193  // mapping corresponding to the data that should be written to each per-builder
   194  // test duration file.
   195  func splitTestsByBuilder(tests []test, opts durationFileOptions) (testDurationMap, error) {
   196  	// Mapping from builder name to list of tests.
   197  	durations := make(testDurationMap)
   198  
   199  	if len(tests) == 0 {
   200  		return durations, nil
   201  	}
   202  
   203  	// Mapping from test name to list of tests, across all builders. Only
   204  	// used as intermediate storage for producing the default test duration file.
   205  	testsByName := make(map[string][]test)
   206  	for _, t := range tests {
   207  		if t.Builder == "" {
   208  			return nil, fmt.Errorf("test has empty builder: %+v", t)
   209  		}
   210  		durations[t.Builder] = append(durations[t.Builder], t)
   211  		testsByName[t.Name] = append(testsByName[t.Name], t)
   212  	}
   213  
   214  	if opts.includeDefaultBuilder {
   215  		defaultDurations, err := calculateDefaultDurations(testsByName)
   216  		if err != nil {
   217  			return nil, err
   218  		}
   219  		durations[defaultBuilderName] = defaultDurations
   220  	}
   221  
   222  	if opts.includeDefaultTests {
   223  		if err := addDefaultEntries(durations); err != nil {
   224  			return nil, err
   225  		}
   226  	}
   227  
   228  	return durations, nil
   229  }
   230  
   231  // calculateDefaultDurations aggregates durations for tests across all builders.
   232  // testsharder will use this default file for any unknown builder that doesn't
   233  // yet have its own durations file.
   234  //
   235  // We use the average duration of all tests, rather than the median, so that
   236  // when using this default file, the sum of all tests' expected durations is
   237  // close to the sum of actual durations. If we used the median of all durations,
   238  // the sum of expected durations would likely be far too low because it wouldn't
   239  // account for the long tail of slower tests, and we would end up producing too
   240  // few shards when executing testsharder with a target per-shard duration.
   241  //
   242  // See unit tests for examples.
   243  func calculateDefaultDurations(testsByName map[string][]test) ([]test, error) {
   244  	var defaultDurations []test
   245  	for name, sameNameTests := range testsByName {
   246  		var totalRuns int64
   247  		for _, t := range sameNameTests {
   248  			totalRuns += t.Runs
   249  		}
   250  
   251  		duration, err := averageDurationMS(sameNameTests)
   252  		if err != nil {
   253  			return nil, err
   254  		}
   255  
   256  		defaultDurations = append(defaultDurations, test{
   257  			Name:             name,
   258  			Builder:          defaultBuilderName,
   259  			Runs:             totalRuns,
   260  			MedianDurationMS: duration,
   261  		})
   262  	}
   263  
   264  	// The SQL query ensures that all other builders' tests are sorted, so we
   265  	// sort these too for consistency.
   266  	sort.Slice(defaultDurations, func(i, j int) bool {
   267  		return defaultDurations[i].Name < defaultDurations[j].Name
   268  	})
   269  
   270  	return defaultDurations, nil
   271  }
   272  
   273  // addDefaultEntries adds an new entry for each builder at index zero that
   274  // will be applied by testsharder to any test that doesn't have its own entry,
   275  // probably because it's a new test or has been renamed.
   276  //
   277  // The average of all existing tests' median durations is probably a good
   278  // estimate of the duration for any such tests. Average of medians is better
   279  // than median of medians in the case where many new tests are added, assuming
   280  // that the distribution of durations of the new tests is similar to the
   281  // distribution for existing tests. (If only a couple tests are added, it
   282  // doesn't make much of a difference either way). The median of medians might be
   283  // closer to the actual duration of *most* of the new tests, but it doesn't take
   284  // into account the fact that a few of the new tests are probably much longer
   285  // than the rest. This would lead to all the new tests being put into the same
   286  // shard, which would always time out because it has too many long tests.
   287  func addDefaultEntries(durations testDurationMap) error {
   288  	for builder, builderTests := range durations {
   289  		defaultDuration, err := averageDurationMS(builderTests)
   290  		if err != nil {
   291  			return err
   292  		}
   293  		defaultTestEntry := test{
   294  			Name:             defaultTestName,
   295  			Builder:          builder,
   296  			MedianDurationMS: defaultDuration,
   297  		}
   298  		durations[builder] = append([]test{defaultTestEntry}, builderTests...)
   299  	}
   300  	return nil
   301  }
   302  
   303  // averageDurationMS calculates the average of median durations, weighted by
   304  // runs, for all the given tests.
   305  func averageDurationMS(tests []test) (int64, error) {
   306  	var totalDurationMS, totalRuns int64
   307  	for _, t := range tests {
   308  		totalRuns += t.Runs
   309  		totalDurationMS += t.MedianDurationMS * t.Runs
   310  	}
   311  	if totalRuns == 0 {
   312  		// This likely indicates a bug somewhere in the SQL query or Go code.
   313  		return 0, fmt.Errorf("%w: %+v", errZeroTotalRuns, tests)
   314  	}
   315  	avg := float64(totalDurationMS) / float64(totalRuns)
   316  	return int64(math.Round(avg)), nil
   317  }
   318  
   319  func marshalDurations(durations testDurationMap) (map[string][]byte, error) {
   320  	files := make(map[string][]byte, len(durations))
   321  	for builder, tests := range durations {
   322  		functools.SortBy(tests, func(t test) string {
   323  			return t.Name
   324  		})
   325  		b, err := json.Marshal(tests)
   326  		if err != nil {
   327  			return nil, err
   328  		}
   329  		fileName := fmt.Sprintf("%s.json", builder)
   330  		files[fileName] = b
   331  	}
   332  
   333  	return files, nil
   334  }
   335  
   336  // previousVersionContents downloads the version of `pkg` identified by `ref`
   337  // and returns a mapping from file basename to file contents for all the non-
   338  // hidden files in the root of the package.
   339  func previousVersionContents(previousDurationsDir string) (map[string][]byte, error) {
   340  	files, err := os.ReadDir(previousDurationsDir)
   341  	if err != nil {
   342  		return nil, err
   343  	}
   344  
   345  	contents := make(map[string][]byte)
   346  	for _, file := range files {
   347  		name := file.Name()
   348  		// Ignore hidden files and directories to ensure we only copy duration
   349  		// files into the updated package, and not any special files installed
   350  		// by CIPD itself.
   351  		if strings.HasPrefix(name, ".") || file.IsDir() {
   352  			continue
   353  		}
   354  		b, err := os.ReadFile(filepath.Join(previousDurationsDir, name))
   355  		if err != nil {
   356  			return nil, err
   357  		}
   358  		contents[name] = b
   359  	}
   360  
   361  	return contents, nil
   362  }
   363  
   364  // overlayFileContents takes an in-memory listing of the current contents of a
   365  // directory and overlays that listing with a listing of new files. It leaves in
   366  // place any entries from `oldFiles` that do not have a corresponding entry in
   367  // `newFiles`.
   368  //
   369  // The purpose of this is to keep around duration files from previous package
   370  // versions even if we don't have any data for their builders from this run of
   371  // the updater. By keeping the old files around, we'll ensure that future builds
   372  // still have test duration data to use.
   373  //
   374  // This is useful if, for example, a builder is paused or breaks for a few days
   375  // and we stop getting data from it, but then it gets fixed/restored. We still
   376  // want the builder to have duration data to use when it comes back online, so
   377  // we intentionally keep its duration file around.
   378  //
   379  // TODO(olivernewman): Set up a garbage collection system so we don't keep old
   380  // files around forever.
   381  func overlayFileContents(oldFiles, newFiles map[string][]byte) map[string][]byte {
   382  	result := make(map[string][]byte)
   383  	maps.Copy(result, oldFiles)
   384  	maps.Copy(result, newFiles)
   385  	return result
   386  }