bazel: Read resultstore link asynchronously

Read the resultstore link asynchronously so it's accessible while the
build is still running.

Bug: b/363338443
Change-Id: Id9908c4c6fd8059909ef9118e21e54869590d81c
Reviewed-on: https://pigweed-review.googlesource.com/c/infra/recipes/+/238815
Commit-Queue: Auto-Submit <auto-submit@pigweed-service-accounts.iam.gserviceaccount.com>
Pigweed-Auto-Submit: Rob Mohr <mohrr@google.com>
Lint: Lint 🤖 <android-build-ayeaye@system.gserviceaccount.com>
Reviewed-by: Ted Pudlik <tpudlik@google.com>
Presubmit-Verified: CQ Bot Account <pigweed-scoped@luci-project-accounts.iam.gserviceaccount.com>
diff --git a/recipe_modules/bazel/__init__.py b/recipe_modules/bazel/__init__.py
index 8250b08..e4c03ec 100644
--- a/recipe_modules/bazel/__init__.py
+++ b/recipe_modules/bazel/__init__.py
@@ -23,9 +23,11 @@
     'recipe_engine/context',
     'recipe_engine/defer',
     'recipe_engine/file',
+    'recipe_engine/futures',
     'recipe_engine/json',
     'recipe_engine/path',
     'recipe_engine/platform',
     'recipe_engine/properties',
     'recipe_engine/step',
+    'recipe_engine/time',
 ]
diff --git a/recipe_modules/bazel/api.py b/recipe_modules/bazel/api.py
index e97e895..26fd954 100644
--- a/recipe_modules/bazel/api.py
+++ b/recipe_modules/bazel/api.py
@@ -183,31 +183,60 @@
                             *args,
                             *base_args,
                         ]
-                        defer(
-                            self.api.step,
-                            shlex.join(args),
-                            cmd,
-                            **kwargs,
-                        )
 
-                        self.api.path.mock_add_file(json_path)
-                        if self.api.path.isfile(json_path):
-                            with self.api.step.nest('resultstore link') as pres:
+                        with self.api.step.nest(shlex.join(args)):
+                            future = self.api.futures.spawn(
+                                defer,
+                                self.api.step,
+                                'bazel',
+                                cmd,
+                                **kwargs,
+                            )
+
+                            # Ensure the bazel step shows up before the
+                            # resultstore link step.
+                            self.api.time.sleep(1)
+
+                            def read_json() -> bool:
+                                if not self.api.path.isfile(json_path):
+                                    return False
+
                                 data = self.api.file.read_json(
-                                    'read',
+                                    f'read {i}',
                                     json_path,
-                                    test_data={
-                                        'resultstore': 'https://result.store/',
-                                    },
+                                    test_data=dict(
+                                        resultstore='https://result.store/',
+                                    ),
                                 )
-                                if 'resultstore' in data:
-                                    pres.links['resultstore'] = data[
-                                        'resultstore'
-                                    ]
-                                else:  # pragma: no cover
-                                    pres.step_summary_text = (
-                                        'no resultstore link found'
-                                    )
+
+                                if 'resultstore' not in data:
+                                    return False  # pragma: no cover
+
+                                pres.links['resultstore'] = data['resultstore']
+                                pres.step_summary_text = ''
+                                return True
+
+                            found_resultstore_link = False
+
+                            with self.api.step.nest('resultstore link') as pres:
+                                pres.step_summary_text = 'link not found'
+
+                                for i in range(1, 5):
+                                    self.api.time.sleep(i)
+
+                                    if i > 1:
+                                        self.api.path.mock_add_file(json_path)
+
+                                    if read_json():
+                                        found_resultstore_link = True
+                                        break
+
+                                    if future.done:
+                                        break  # pragma: no cover
+
+                                _ = future.result()
+                                if not found_resultstore_link:
+                                    read_json()  # pragma: no cover
 
 
 class BazelApi(recipe_api.RecipeApi):
diff --git a/recipe_modules/bazel/tests/full.py b/recipe_modules/bazel/tests/full.py
index 84fb1e5..da8c964 100644
--- a/recipe_modules/bazel/tests/full.py
+++ b/recipe_modules/bazel/tests/full.py
@@ -72,7 +72,7 @@
         check = post_process.StepCommandContains
         if invert:
             check = post_process.StepCommandDoesNotContain
-        return api.post_process(check, step, pattern)
+        return api.post_process(check, f'{step}.bazel', pattern)
 
     def lacks_override(step: str, name: str, bzlmod=False):
         return contains_override(
diff --git a/recipes/bazel.expected/simple.json b/recipes/bazel.expected/simple.json
index 5a78107..f837b92 100644
--- a/recipes/bazel.expected/simple.json
+++ b/recipes/bazel.expected/simple.json
@@ -1207,54 +1207,17 @@
     "name": "default"
   },
   {
-    "cmd": [
-      "RECIPE_MODULE[pigweed::bazel]/resources/wrapper.py",
-      "--json",
-      "[CLEANUP]/tmp_tmp_3/metadata.json",
-      "--",
-      "[CLEANUP]/tmp_tmp_2/bazelisk",
-      "build",
-      "//...",
-      "--experimental_ui_max_stdouterr_bytes=-1",
-      "--config=remote_cache",
-      "--bes_instance_name=pigweed-rbe-private",
-      "--remote_instance_name=projects/pigweed-rbe-private/instances/default_instance",
-      "--remote_upload_local_results=true"
-    ],
-    "cwd": "[START_DIR]/co",
-    "env": {
-      "BUILDBUCKET_ID": "0",
-      "BUILDBUCKET_NAME": "project:bucket:builder",
-      "BUILD_NUMBER": "0",
-      "CCACHE_DIR": "[CACHE]/ccache",
-      "CLICOLOR": "0",
-      "CLICOLOR_FORCE": "0",
-      "CTCACHE_DIR": "[CACHE]/clang_tidy",
-      "GCC_COLORS": "",
-      "GOCACHE": "[CACHE]/go",
-      "NO_COLOR": "1",
-      "PIP_CACHE_DIR": "[CACHE]/pip",
-      "PW_ENVIRONMENT_NO_ERROR_ON_UNRECOGNIZED": "1",
-      "PW_ENVSETUP_DISABLE_SPINNER": "1",
-      "PW_PRESUBMIT_DISABLE_SUBPROCESS_CAPTURE": "1",
-      "PW_TEST_VAR": "test_value",
-      "PW_USE_COLOR": "",
-      "TEST_TMPDIR": "[CACHE]/bazel",
-      "TRIGGERING_CHANGES_JSON": "[CLEANUP]/tmp_tmp_1"
-    },
+    "cmd": [],
     "name": "default.build //...",
     "~followup_annotations": [
-      "@@@STEP_NEST_LEVEL@1@@@",
-      "@@@STEP_LOG_LINE@json.output (read error)@JSON file was missing or unreadable:@@@",
-      "@@@STEP_LOG_LINE@json.output (read error)@  [CLEANUP]/tmp_tmp_3/metadata.json@@@",
-      "@@@STEP_LOG_END@json.output (read error)@@@"
+      "@@@STEP_NEST_LEVEL@1@@@"
     ]
   },
   {
     "cmd": [],
-    "name": "default.resultstore link",
+    "name": "default.build //....resultstore link",
     "~followup_annotations": [
-      "@@@STEP_NEST_LEVEL@1@@@",
+      "@@@STEP_NEST_LEVEL@2@@@",
       "@@@STEP_LINK@resultstore@https://result.store/@@@"
     ]
   },
@@ -1291,9 +1254,110 @@
       "TRIGGERING_CHANGES_JSON": "[CLEANUP]/tmp_tmp_1"
     },
     "infra_step": true,
-    "name": "default.resultstore link.read",
+    "name": "default.build //....resultstore link.read 2",
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@3@@@",
+      "@@@STEP_LOG_LINE@metadata.json@{@@@",
+      "@@@STEP_LOG_LINE@metadata.json@  \"resultstore\": \"https://result.store/\"@@@",
+      "@@@STEP_LOG_LINE@metadata.json@}@@@",
+      "@@@STEP_LOG_END@metadata.json@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "RECIPE_MODULE[pigweed::bazel]/resources/wrapper.py",
+      "--json",
+      "[CLEANUP]/tmp_tmp_3/metadata.json",
+      "--",
+      "[CLEANUP]/tmp_tmp_2/bazelisk",
+      "build",
+      "//...",
+      "--experimental_ui_max_stdouterr_bytes=-1",
+      "--config=remote_cache",
+      "--bes_instance_name=pigweed-rbe-private",
+      "--remote_instance_name=projects/pigweed-rbe-private/instances/default_instance",
+      "--remote_upload_local_results=true"
+    ],
+    "cwd": "[START_DIR]/co",
+    "env": {
+      "BUILDBUCKET_ID": "0",
+      "BUILDBUCKET_NAME": "project:bucket:builder",
+      "BUILD_NUMBER": "0",
+      "CCACHE_DIR": "[CACHE]/ccache",
+      "CLICOLOR": "0",
+      "CLICOLOR_FORCE": "0",
+      "CTCACHE_DIR": "[CACHE]/clang_tidy",
+      "GCC_COLORS": "",
+      "GOCACHE": "[CACHE]/go",
+      "NO_COLOR": "1",
+      "PIP_CACHE_DIR": "[CACHE]/pip",
+      "PW_ENVIRONMENT_NO_ERROR_ON_UNRECOGNIZED": "1",
+      "PW_ENVSETUP_DISABLE_SPINNER": "1",
+      "PW_PRESUBMIT_DISABLE_SUBPROCESS_CAPTURE": "1",
+      "PW_TEST_VAR": "test_value",
+      "PW_USE_COLOR": "",
+      "TEST_TMPDIR": "[CACHE]/bazel",
+      "TRIGGERING_CHANGES_JSON": "[CLEANUP]/tmp_tmp_1"
+    },
+    "name": "default.build //....bazel",
     "~followup_annotations": [
       "@@@STEP_NEST_LEVEL@2@@@",
+      "@@@STEP_LOG_LINE@json.output (read error)@JSON file was missing or unreadable:@@@",
+      "@@@STEP_LOG_LINE@json.output (read error)@  [CLEANUP]/tmp_tmp_3/metadata.json@@@",
+      "@@@STEP_LOG_END@json.output (read error)@@@"
+    ]
+  },
+  {
+    "cmd": [],
+    "name": "default.test //...",
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@1@@@"
+    ]
+  },
+  {
+    "cmd": [],
+    "name": "default.test //....resultstore link",
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@2@@@",
+      "@@@STEP_LINK@resultstore@https://result.store/@@@"
+    ]
+  },
+  {
+    "cmd": [
+      "vpython3",
+      "-u",
+      "RECIPE_MODULE[recipe_engine::file]/resources/fileutil.py",
+      "--json-output",
+      "/path/to/tmp/json",
+      "copy",
+      "[CLEANUP]/tmp_tmp_4/metadata.json",
+      "/path/to/tmp/"
+    ],
+    "cwd": "[START_DIR]/co",
+    "env": {
+      "BUILDBUCKET_ID": "0",
+      "BUILDBUCKET_NAME": "project:bucket:builder",
+      "BUILD_NUMBER": "0",
+      "CCACHE_DIR": "[CACHE]/ccache",
+      "CLICOLOR": "0",
+      "CLICOLOR_FORCE": "0",
+      "CTCACHE_DIR": "[CACHE]/clang_tidy",
+      "GCC_COLORS": "",
+      "GOCACHE": "[CACHE]/go",
+      "NO_COLOR": "1",
+      "PIP_CACHE_DIR": "[CACHE]/pip",
+      "PW_ENVIRONMENT_NO_ERROR_ON_UNRECOGNIZED": "1",
+      "PW_ENVSETUP_DISABLE_SPINNER": "1",
+      "PW_PRESUBMIT_DISABLE_SUBPROCESS_CAPTURE": "1",
+      "PW_TEST_VAR": "test_value",
+      "PW_USE_COLOR": "",
+      "TEST_TMPDIR": "[CACHE]/bazel",
+      "TRIGGERING_CHANGES_JSON": "[CLEANUP]/tmp_tmp_1"
+    },
+    "infra_step": true,
+    "name": "default.test //....resultstore link.read 2",
+    "~followup_annotations": [
+      "@@@STEP_NEST_LEVEL@3@@@",
       "@@@STEP_LOG_LINE@metadata.json@{@@@",
       "@@@STEP_LOG_LINE@metadata.json@  \"resultstore\": \"https://result.store/\"@@@",
       "@@@STEP_LOG_LINE@metadata.json@}@@@",
@@ -1336,65 +1400,15 @@
       "TEST_TMPDIR": "[CACHE]/bazel",
       "TRIGGERING_CHANGES_JSON": "[CLEANUP]/tmp_tmp_1"
     },
-    "name": "default.test //...",
+    "name": "default.test //....bazel",
     "~followup_annotations": [
-      "@@@STEP_NEST_LEVEL@1@@@",
+      "@@@STEP_NEST_LEVEL@2@@@",
       "@@@STEP_LOG_LINE@json.output (read error)@JSON file was missing or unreadable:@@@",
       "@@@STEP_LOG_LINE@json.output (read error)@  [CLEANUP]/tmp_tmp_4/metadata.json@@@",
       "@@@STEP_LOG_END@json.output (read error)@@@"
     ]
   },
   {
-    "cmd": [],
-    "name": "default.resultstore link (2)",
-    "~followup_annotations": [
-      "@@@STEP_NEST_LEVEL@1@@@",
-      "@@@STEP_LINK@resultstore@https://result.store/@@@"
-    ]
-  },
-  {
-    "cmd": [
-      "vpython3",
-      "-u",
-      "RECIPE_MODULE[recipe_engine::file]/resources/fileutil.py",
-      "--json-output",
-      "/path/to/tmp/json",
-      "copy",
-      "[CLEANUP]/tmp_tmp_4/metadata.json",
-      "/path/to/tmp/"
-    ],
-    "cwd": "[START_DIR]/co",
-    "env": {
-      "BUILDBUCKET_ID": "0",
-      "BUILDBUCKET_NAME": "project:bucket:builder",
-      "BUILD_NUMBER": "0",
-      "CCACHE_DIR": "[CACHE]/ccache",
-      "CLICOLOR": "0",
-      "CLICOLOR_FORCE": "0",
-      "CTCACHE_DIR": "[CACHE]/clang_tidy",
-      "GCC_COLORS": "",
-      "GOCACHE": "[CACHE]/go",
-      "NO_COLOR": "1",
-      "PIP_CACHE_DIR": "[CACHE]/pip",
-      "PW_ENVIRONMENT_NO_ERROR_ON_UNRECOGNIZED": "1",
-      "PW_ENVSETUP_DISABLE_SPINNER": "1",
-      "PW_PRESUBMIT_DISABLE_SUBPROCESS_CAPTURE": "1",
-      "PW_TEST_VAR": "test_value",
-      "PW_USE_COLOR": "",
-      "TEST_TMPDIR": "[CACHE]/bazel",
-      "TRIGGERING_CHANGES_JSON": "[CLEANUP]/tmp_tmp_1"
-    },
-    "infra_step": true,
-    "name": "default.resultstore link (2).read",
-    "~followup_annotations": [
-      "@@@STEP_NEST_LEVEL@2@@@",
-      "@@@STEP_LOG_LINE@metadata.json@{@@@",
-      "@@@STEP_LOG_LINE@metadata.json@  \"resultstore\": \"https://result.store/\"@@@",
-      "@@@STEP_LOG_LINE@metadata.json@}@@@",
-      "@@@STEP_LOG_END@metadata.json@@@"
-    ]
-  },
-  {
     "name": "$result"
   }
 ]
\ No newline at end of file