[testsharder] Add target_shard_duration_secs to spec

And pass it from the spec to the testsharder `-target-duration-secs`
flag. This replaces the old `-max-shard-size` flag, with the goal of
producing shards of roughly equal durations even for builds that use
multiple device types.

This is dependent on I21293df0c2b512c45c2a293aeffa39f9e2edf8a1, although
because no builders' specs set the target_shard_duration_secs field yet,
it's safe to land this even before that change rolls.

Bug: 43705
Change-Id: I066e7c97d0896a3928338e50285e43abc1453727
diff --git a/recipe_modules/testsharder/api.py b/recipe_modules/testsharder/api.py
index 7a36bdf..e590e79 100644
--- a/recipe_modules/testsharder/api.py
+++ b/recipe_modules/testsharder/api.py
@@ -223,6 +223,7 @@
               testsharder_path,
               build_dir,
               max_shard_size=None,
+              target_duration_secs=0,
               mode=None,
               multipliers=None,
               output_file=None,
@@ -236,6 +237,8 @@
         which GN has been run (ninja need not have been executed).
       max_shard_size (long or None):  Additional shards will be created if needed
         to keep the number of tests per shard <= this number.
+      target_duration_secs (int): If >0, testsharder will try to produce
+        shards of approximately this duration.
       mode (str or None): mode in which the testsharder may run (e.g.,
         normal or restricted); if None, the default normal mode will
         be used.
@@ -258,6 +261,8 @@
     ]
     if max_shard_size:
       cmd += ['-max-shard-size', max_shard_size]
+    if target_duration_secs:
+      cmd += ['-target-duration-secs', target_duration_secs]
     if multipliers:
       cmd += ['-multipliers', multipliers]
     for tag in tags:
diff --git a/recipe_modules/testsharder/examples/full.expected/basic.json b/recipe_modules/testsharder/examples/full.expected/basic.json
index 50b5b65..3053307 100644
--- a/recipe_modules/testsharder/examples/full.expected/basic.json
+++ b/recipe_modules/testsharder/examples/full.expected/basic.json
@@ -8,6 +8,8 @@
       "[START_DIR]/leak_output_here",
       "-max-shard-size",
       "200",
+      "-target-duration-secs",
+      "600",
       "-tag",
       "one-tag",
       "-tag",
diff --git a/recipe_modules/testsharder/examples/full.expected/shard_with_multiplied_tests.json b/recipe_modules/testsharder/examples/full.expected/shard_with_multiplied_tests.json
index 2f1ed87..0d32dba 100644
--- a/recipe_modules/testsharder/examples/full.expected/shard_with_multiplied_tests.json
+++ b/recipe_modules/testsharder/examples/full.expected/shard_with_multiplied_tests.json
@@ -8,6 +8,8 @@
       "[START_DIR]/leak_output_here",
       "-max-shard-size",
       "200",
+      "-target-duration-secs",
+      "600",
       "-multipliers",
       "test_multipliers.json",
       "-tag",
diff --git a/recipe_modules/testsharder/examples/full.py b/recipe_modules/testsharder/examples/full.py
index 7d5ac81..49cefae 100644
--- a/recipe_modules/testsharder/examples/full.py
+++ b/recipe_modules/testsharder/examples/full.py
@@ -26,7 +26,11 @@
       'shard test specs',
       testsharder_path='path/to/testsharder',
       build_dir=api.path['start_dir'].join('out'),
+      # It's actually invalid to pass both `max_shard_size` and
+      # `target_duration_secs` to the testshadder executable; we just do it
+      # here for code coverage.
       max_shard_size=200,
+      target_duration_secs=10 * 60,
       mode='restricted',
       multipliers=multipliers,
       output_file=api.path['start_dir'].join('leak_output_here'),
diff --git a/recipe_proto/infra/fuchsia.proto b/recipe_proto/infra/fuchsia.proto
index 18ea9f4..93e8722 100644
--- a/recipe_proto/infra/fuchsia.proto
+++ b/recipe_proto/infra/fuchsia.proto
@@ -184,7 +184,9 @@
     // Whether to upload perf results to Catapult
     bool upload_to_catapult = 10;
 
-    // Maximum number of tests in a given testing swarming task
+    // Target number of tests in a given testing swarming task ("max" name is a
+    // historical artifact).
+    // Deprecated in favor of target_shard_duration_secs
     int32 max_shard_size = 11;
 
     // Maximum number of attempts to make for each individual test or shard.
@@ -219,5 +221,9 @@
     // If >0, will keep launching tasks until this many seconds have
     // elapsed. max_attempts should be set to 1 if this is >0.
     uint32 rerun_budget_secs = 20;
+
+    // If >0, will distribute tests into shards that are each expected to take
+    // approximately this duration.
+    uint32 target_shard_duration_secs = 21;
   }
 }
diff --git a/recipes/fuchsia/build.expected/default.json b/recipes/fuchsia/build.expected/default.json
index 879d1a1..f01556d 100644
--- a/recipes/fuchsia/build.expected/default.json
+++ b/recipes/fuchsia/build.expected/default.json
@@ -34,6 +34,7 @@
       "@@@STEP_LOG_LINE@textproto@  timeout_secs: 1800@@@",
       "@@@STEP_LOG_LINE@textproto@  default_service_account: \"service_account\"@@@",
       "@@@STEP_LOG_LINE@textproto@  targets_serial: true@@@",
+      "@@@STEP_LOG_LINE@textproto@  target_shard_duration_secs: 600@@@",
       "@@@STEP_LOG_LINE@textproto@}@@@",
       "@@@STEP_LOG_LINE@textproto@gcs_bucket: \"fuchsia-infra\"@@@",
       "@@@STEP_LOG_LINE@textproto@artifact_gcs_bucket: \"fuchsia-infra-artifacts\"@@@",
@@ -2308,7 +2309,9 @@
       "-build-dir",
       "[START_DIR]/fuchsia/out/default",
       "-output-file",
-      "/path/to/tmp/json"
+      "/path/to/tmp/json",
+      "-target-duration-secs",
+      "600"
     ],
     "name": "create test shards",
     "~followup_annotations": [
diff --git a/recipes/fuchsia/build.expected/default_cq.json b/recipes/fuchsia/build.expected/default_cq.json
index 5cbbabc..c35814e 100644
--- a/recipes/fuchsia/build.expected/default_cq.json
+++ b/recipes/fuchsia/build.expected/default_cq.json
@@ -134,6 +134,7 @@
       "@@@STEP_LOG_LINE@textproto@  timeout_secs: 1800@@@",
       "@@@STEP_LOG_LINE@textproto@  default_service_account: \"service_account\"@@@",
       "@@@STEP_LOG_LINE@textproto@  targets_serial: true@@@",
+      "@@@STEP_LOG_LINE@textproto@  target_shard_duration_secs: 600@@@",
       "@@@STEP_LOG_LINE@textproto@}@@@",
       "@@@STEP_LOG_LINE@textproto@artifact_gcs_bucket: \"fuchsia-infra-artifacts\"@@@",
       "@@@STEP_LOG_LINE@textproto@debug_symbol_gcs_bucket: \"debug-symbols\"@@@",
@@ -2172,6 +2173,8 @@
       "[START_DIR]/fuchsia/out/default",
       "-output-file",
       "/path/to/tmp/json",
+      "-target-duration-secs",
+      "600",
       "-multipliers",
       "[CLEANUP]/tmp_tmp_3"
     ],
diff --git a/recipes/fuchsia/build.expected/non-numeric-parent-id.json b/recipes/fuchsia/build.expected/non-numeric-parent-id.json
index 9c5a096..8c068be 100644
--- a/recipes/fuchsia/build.expected/non-numeric-parent-id.json
+++ b/recipes/fuchsia/build.expected/non-numeric-parent-id.json
@@ -34,6 +34,7 @@
       "@@@STEP_LOG_LINE@textproto@  timeout_secs: 1800@@@",
       "@@@STEP_LOG_LINE@textproto@  default_service_account: \"service_account\"@@@",
       "@@@STEP_LOG_LINE@textproto@  targets_serial: true@@@",
+      "@@@STEP_LOG_LINE@textproto@  target_shard_duration_secs: 600@@@",
       "@@@STEP_LOG_LINE@textproto@}@@@",
       "@@@STEP_LOG_LINE@textproto@gcs_bucket: \"fuchsia-infra\"@@@",
       "@@@STEP_LOG_LINE@textproto@artifact_gcs_bucket: \"fuchsia-infra-artifacts\"@@@",
@@ -2308,7 +2309,9 @@
       "-build-dir",
       "[START_DIR]/fuchsia/out/default",
       "-output-file",
-      "/path/to/tmp/json"
+      "/path/to/tmp/json",
+      "-target-duration-secs",
+      "600"
     ],
     "name": "create test shards",
     "~followup_annotations": [
diff --git a/recipes/fuchsia/build.expected/not_test_in_shards.json b/recipes/fuchsia/build.expected/not_test_in_shards.json
index 4197861..6f3c443 100644
--- a/recipes/fuchsia/build.expected/not_test_in_shards.json
+++ b/recipes/fuchsia/build.expected/not_test_in_shards.json
@@ -31,6 +31,7 @@
       "@@@STEP_LOG_LINE@textproto@  timeout_secs: 1800@@@",
       "@@@STEP_LOG_LINE@textproto@  default_service_account: \"service_account\"@@@",
       "@@@STEP_LOG_LINE@textproto@  targets_serial: true@@@",
+      "@@@STEP_LOG_LINE@textproto@  target_shard_duration_secs: 600@@@",
       "@@@STEP_LOG_LINE@textproto@}@@@",
       "@@@STEP_LOG_LINE@textproto@artifact_gcs_bucket: \"fuchsia-infra-artifacts\"@@@",
       "@@@STEP_LOG_LINE@textproto@debug_symbol_gcs_bucket: \"debug-symbols\"@@@",
@@ -1965,7 +1966,9 @@
       "-build-dir",
       "[START_DIR]/fuchsia/out/default",
       "-output-file",
-      "/path/to/tmp/json"
+      "/path/to/tmp/json",
+      "-target-duration-secs",
+      "600"
     ],
     "name": "create test shards",
     "~followup_annotations": [
diff --git a/recipes/fuchsia/build.expected/spec_remote_cq.json b/recipes/fuchsia/build.expected/spec_remote_cq.json
index e3aa9f9..e58ad01 100644
--- a/recipes/fuchsia/build.expected/spec_remote_cq.json
+++ b/recipes/fuchsia/build.expected/spec_remote_cq.json
@@ -134,6 +134,7 @@
       "@@@STEP_LOG_LINE@textproto@  timeout_secs: 1800@@@",
       "@@@STEP_LOG_LINE@textproto@  default_service_account: \"service_account\"@@@",
       "@@@STEP_LOG_LINE@textproto@  targets_serial: true@@@",
+      "@@@STEP_LOG_LINE@textproto@  target_shard_duration_secs: 600@@@",
       "@@@STEP_LOG_LINE@textproto@}@@@",
       "@@@STEP_LOG_LINE@textproto@artifact_gcs_bucket: \"fuchsia-infra-artifacts\"@@@",
       "@@@STEP_LOG_LINE@textproto@debug_symbol_gcs_bucket: \"debug-symbols\"@@@",
@@ -2194,6 +2195,8 @@
       "[START_DIR]/fuchsia/out/default",
       "-output-file",
       "/path/to/tmp/json",
+      "-target-duration-secs",
+      "600",
       "-multipliers",
       "[CLEANUP]/tmp_tmp_3"
     ],
diff --git a/recipes/fuchsia/build.expected/spec_remote_cq_with_spec_revision.json b/recipes/fuchsia/build.expected/spec_remote_cq_with_spec_revision.json
index c4d4ad8..896183d 100644
--- a/recipes/fuchsia/build.expected/spec_remote_cq_with_spec_revision.json
+++ b/recipes/fuchsia/build.expected/spec_remote_cq_with_spec_revision.json
@@ -134,6 +134,7 @@
       "@@@STEP_LOG_LINE@textproto@  timeout_secs: 1800@@@",
       "@@@STEP_LOG_LINE@textproto@  default_service_account: \"service_account\"@@@",
       "@@@STEP_LOG_LINE@textproto@  targets_serial: true@@@",
+      "@@@STEP_LOG_LINE@textproto@  target_shard_duration_secs: 600@@@",
       "@@@STEP_LOG_LINE@textproto@}@@@",
       "@@@STEP_LOG_LINE@textproto@artifact_gcs_bucket: \"fuchsia-infra-artifacts\"@@@",
       "@@@STEP_LOG_LINE@textproto@debug_symbol_gcs_bucket: \"debug-symbols\"@@@",
@@ -2194,6 +2195,8 @@
       "[START_DIR]/fuchsia/out/default",
       "-output-file",
       "/path/to/tmp/json",
+      "-target-duration-secs",
+      "600",
       "-multipliers",
       "[CLEANUP]/tmp_tmp_3"
     ],
diff --git a/recipes/fuchsia/build.py b/recipes/fuchsia/build.py
index 61692ec..2d4c00e 100644
--- a/recipes/fuchsia/build.py
+++ b/recipes/fuchsia/build.py
@@ -223,6 +223,7 @@
       testsharder_path=build.tool('testsharder'),
       build_dir=build.fuchsia_build_dir,
       max_shard_size=spec.test.max_shard_size,
+      target_duration_secs=spec.test.target_shard_duration_secs,
       multipliers=multipliers_path,
       tags=spec.build.environment_tags,
   )
@@ -305,6 +306,7 @@
       test_spec = Fuchsia.Test(
           device_type=device_type,
           max_shard_size=0,
+          target_shard_duration_secs=10 * 60,
           timeout_secs=30 * 60,
           pool='fuchsia.tests',
           test_in_shards=test_in_shards,