fix(stamping): skip build data spawn when --stamp=false (#4160) ## Summary `PyWriteBuildData` is dirty on every build with a cold Skyframe when outputs are not downloaded. That is, once per `py_binary` and `py_test` in the build. When stamping is off, the output is a constant, so the action spawn is pure overhead. ## Root Cause Analysis The output is declared with `_py_builtins.declare_constant_metadata_file`. Constant metadata always reports itself as unchanged, so Bazel cannot validate it from the action cache. It stats the output on disk instead. Under `--remote_download_outputs=minimal` the output is not on disk. The stat fails and the action is dirty. It re-executes, hits the remote cache, is still not downloaded, and is dirty again on the next cold-server build. Setting `--remote_download_regex` to match the file avoids this, at the cost of downloading it, which confirms the stat is the trigger. The use of a constant metadata file is justified (as per the comment in the code). With stamping on, the action reads `volatile-status.txt`, which changes every build. The build data file is in the binary's runfiles, so without it its consumers would invalidate on every build due to timestamps. However, with stamping off, there is no volatile input. There is nothing to shield. Unfortunately, the current implementation keeps the _additional_ cost (constant metadata being always unchanged plus failed `stat` syscalls) for none of the benefits. ## Change When `is_stamping_enabled(ctx)` is false, the content of the file is known at analysis time: ``` TARGET <label> STAMPED FALSE ``` So declare an ordinary output and write it with `ctx.actions.write`. No spawn, and no constant metadata output. Note this is broader than `--nostamp`. `is_stamping_enabled` is also false for the exec configuration and for `stamp = 0`, so tool-config binaries benefit from the fix even when `--stamp=true` is set at the top level. The stamped path is unchanged and keeps its constant metadata output. ## Compatibility The output is byte-identical. The label is written with `str(ctx.label)` to match `"TARGET": str(ctx.label)` in the spawn's env, which yields the canonical `@@//pkg:target` form. `ctx.actions.args().add(ctx.label)` renders the display form and drops the `@@`. After the change, the tests in `tests/build_data/build_data_test.py` pass without any modifications. ## Alternative considered Keeping the spawn and declaring an ordinary output would also fix the dirtiness. Writing at analysis time is lighter, since it needs no subprocess. ## Environment rules_python 2.2.0, Bazel 9.2.0, Linux. Reproduce with `--remote_download_outputs=minimal` against a warm remote cache, then `bazel shutdown` and rebuild.
diff --git a/news/4160.fixed.md b/news/4160.fixed.md new file mode 100644 index 0000000..5f87118 --- /dev/null +++ b/news/4160.fixed.md
@@ -0,0 +1,2 @@ +(stamping) The build data spawn action is now skipped when `--stamp=false` and, +instead the metadata file is written at analysis time via `ctx.actions.write()`.
diff --git a/python/private/py_executable.bzl b/python/private/py_executable.bzl index f7386ee..62742d3 100644 --- a/python/private/py_executable.bzl +++ b/python/private/py_executable.bzl
@@ -1634,17 +1634,31 @@ ) def _write_build_data(ctx): + build_data_name = ctx.label.name + ".build_data.txt" + + if not is_stamping_enabled(ctx): + # Without stamping there are no volatile inputs, so the content is known + # at analysis time. Write it directly. This skips a spawn per executable. + # It also skips the constant metadata output below, which never reports + # as up to date. + build_data = ctx.actions.declare_file(build_data_name) + + ctx.actions.write( + output = build_data, + content = ctx.actions.args() + .set_param_file_format("multiline") + .add(str(ctx.label), format = "TARGET %s") + .add("STAMPED FALSE"), + ) + + return build_data + + info_file = ctx.info_file + version_file = ctx.files._uncachable_version_file[0] + inputs = builders.DepsetBuilder() - if is_stamping_enabled(ctx): - # NOTE: ctx.info_file is undocumented; see - # https://github.com/bazelbuild/bazel/issues/9363 - info_file = ctx.info_file - version_file = ctx.files._uncachable_version_file[0] - inputs.add(info_file) - inputs.add(version_file) - else: - info_file = None - version_file = None + inputs.add(info_file) + inputs.add(version_file) # A "constant metadata" file is basically a special file that doesn't # support change detection logic and reports that it is unchanged. i.e., it @@ -1670,9 +1684,15 @@ # mostly works. Google's RBE, unfortunately, rejects it. # * A binary's transitive closure may be so large that it exceeds # Google RBE limits for action inputs. + # + # The cost is that this action never reports as up to date. Constant + # metadata cannot be restored from the action cache, so ActionCacheChecker + # stats the output instead. That stat fails when outputs are not downloaded, + # and the action re-runs. The trade is worth it here, because the inputs do + # really change every build. build_data = _py_builtins.declare_constant_metadata_file( ctx = ctx, - name = ctx.label.name + ".build_data.txt", + name = build_data_name, root = ctx.bin_dir, ) @@ -1698,13 +1718,13 @@ executable = action_exe, arguments = [action_args], env = { - "INFO_FILE": info_file.path if info_file else "", + "INFO_FILE": info_file.path, "OUTPUT": build_data.path, # Include this so it's explicit, otherwise, one has to detect # this by looking for the absense of info_file keys. - "STAMPED": "TRUE" if is_stamping_enabled(ctx) else "FALSE", + "STAMPED": "TRUE", "TARGET": str(ctx.label), - "VERSION_FILE": version_file.path if version_file else "", + "VERSION_FILE": version_file.path, }, inputs = inputs.build(), outputs = [build_data],