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 }