Skip to content

Commit 9984372

Browse files
committed
fix: correctly invalidate treeartifacts
1 parent 19f6303 commit 9984372

4 files changed

Lines changed: 302 additions & 1 deletion

File tree

.github/workflows/ci.yaml

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,40 @@ jobs:
2828
- uses: actions/checkout@v4
2929
- uses: bazel-contrib/setup-bazel@0.15.0
3030
- run: bazel test //...
31+
# Theory 1: verify that changing a TreeArtifact's *content* (same file paths)
32+
# invalidates the tar action's cache when compute_unused_inputs is enabled.
33+
#
34+
# The test builds //tar/tests:tar_theory1 twice with a shared --disk_cache.
35+
# Between the two builds it rewrites tar/tests/whl_module.py so that the
36+
# theory1_whl_install TreeArtifact has different content. If whl_install is
37+
# incorrectly pruned from the tar action's cache key the archive will be a
38+
# stale cache hit; otherwise the archive must change to reflect the new content.
39+
cache_invalidation_test:
40+
strategy:
41+
matrix:
42+
os:
43+
- macos-latest
44+
- ubuntu-latest
45+
runs-on: ${{ matrix.os }}
46+
steps:
47+
- uses: actions/checkout@v4
48+
- uses: bazel-contrib/setup-bazel@0.15.0
49+
- name: Build v1 (populate disk cache)
50+
run: |
51+
echo "# whl content v1" > tar/tests/whl_module.py
52+
bazel build //tar/tests:tar_theory1 --disk_cache=/tmp/theory1_cache
53+
cp bazel-bin/tar/tests/theory1.tar /tmp/theory1_v1.tar
54+
- name: Change whl content and rebuild
55+
run: |
56+
echo "# whl content v2 — different from v1" > tar/tests/whl_module.py
57+
bazel build //tar/tests:tar_theory1 --disk_cache=/tmp/theory1_cache
58+
- name: Verify archive changed (cache correctly invalidated)
59+
run: |
60+
if cmp -s bazel-bin/tar/tests/theory1.tar /tmp/theory1_v1.tar; then
61+
echo "FAIL: archive did not change after whl content change"
62+
echo " theory1_whl_install was incorrectly pruned from the tar cache key"
63+
exit 1
64+
fi
3165
pre-commit:
3266
runs-on: ubuntu-latest
3367
steps:
@@ -36,7 +70,7 @@ jobs:
3670
# For branch protection settings, this job provides a "stable" name that can be used to gate PR merges
3771
# on "all matrix jobs were successful".
3872
conclusion:
39-
needs: [test, pre-commit]
73+
needs: [test, cache_invalidation_test, pre-commit]
4074
runs-on: ubuntu-latest
4175
if: always()
4276
steps:

tar/tests/BUILD

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ load("@tar.bzl", "mtree_mutate", "mtree_spec", "tar")
88
load(":asserts.bzl", "assert_tar_listing", "assert_unused_listing")
99
load(":directory.bzl", "directory")
1010
load(":node_modules_tree.bzl", "node_modules_tree")
11+
load(":cross_linking_tree.bzl", "venv_binary", "venv_binary_independent", "whl_install", "whl_install_from_file")
1112
load(":runfiles_symlinks.bzl", "runfiles_symlinks")
1213

1314
# The examples below work with both source files and generated files.
@@ -827,3 +828,133 @@ assert_tar_listing(
827828
"-rw-r--r-- 0 0 0 21 Jan 1 2023 src_file",
828829
],
829830
)
831+
832+
#############
833+
# Example 22: compute_unused_inputs with directory-level cross-TreeArtifact symlinks.
834+
#
835+
# node_modules_tree produces TreeArtifacts where dep_symlink_b_to_a and node_modules_a
836+
# are directory-level symlinks to store_a. Both the symlink directories AND store_a
837+
# itself are in e17_binary's runfiles, so the mtree contains content= entries for
838+
# store_a's files directly. The pruner therefore correctly does not prune store_a.
839+
#
840+
# This is a regression test: it verifies that directory-level symlink TreeArtifacts
841+
# in runfiles are handled correctly and nothing is incorrectly pruned.
842+
843+
mtree_spec(
844+
name = "mtree22",
845+
srcs = [":e17_binary"],
846+
)
847+
848+
tar(
849+
name = "tar22",
850+
srcs = [":e17_binary"],
851+
out = "22.tar",
852+
compute_unused_inputs = 1,
853+
mtree = ":mtree22",
854+
)
855+
856+
# store_a backing files must NOT appear in unused_inputs; they are needed to
857+
# resolve dep_symlink_b_to_a → store_a and node_modules_a → store_a at runtime.
858+
assert_unused_listing(
859+
name = "test22_cross_dir_symlink_treeartifact_not_pruned",
860+
actual = ":tar22",
861+
expected = [],
862+
)
863+
864+
#############
865+
# Example 23: compute_unused_inputs with file-level cross-TreeArtifact symlinks.
866+
#
867+
# cross_linking_tree produces a binary whose runfiles contain:
868+
# - *_whl_install: a real TreeArtifact with module.py
869+
# - *_venv: a TreeArtifact containing lib/module.py as a file-level symlink into whl_install
870+
#
871+
# The mtree content= paths reference files under *_venv/ (e.g. venv/lib/module.py).
872+
# The *_whl_install artifact path (whl_install/module.py) never appears in any mtree
873+
# content=, so the join marks whl_install files as unused. At runtime bsdtar
874+
# dereferences venv/lib/module.py → whl_install/module.py and fails if whl_install
875+
# has been pruned from the sandbox.
876+
#
877+
# This test is expected to FAIL until the pruner is fixed.
878+
879+
whl_install(name = "e23_whl_install")
880+
881+
venv_binary(
882+
name = "e23_venv_binary",
883+
whl_install = ":e23_whl_install",
884+
)
885+
886+
# mtree is derived only from e23_venv_binary's runfiles. The venv contains
887+
# file-level symlinks into e23_whl_install, so the mtree content= paths are
888+
# e23_venv_binary_venv/lib/module.py (the symlink path), never the backing
889+
# e23_whl_install/module.py path. The pruner therefore marks e23_whl_install
890+
# files as unused even though bsdtar must dereference the symlinks at
891+
# archive-creation time.
892+
893+
mtree_spec(
894+
name = "mtree23",
895+
srcs = [":e23_venv_binary"],
896+
)
897+
898+
tar(
899+
name = "tar23",
900+
srcs = [
901+
":e23_venv_binary",
902+
":e23_whl_install", # needed by bsdtar via symlink dereference, not in mtree
903+
],
904+
out = "23.tar",
905+
compute_unused_inputs = 1,
906+
mtree = ":mtree23",
907+
)
908+
909+
# e23_whl_install files must NOT appear in unused_inputs even though no mtree
910+
# content= directly names them: bsdtar dereferences
911+
# e23_venv_binary_venv/lib/module.py → e23_whl_install/module.py
912+
# at archive-creation time and fails if e23_whl_install is absent from the sandbox.
913+
assert_unused_listing(
914+
name = "test23_file_level_cross_treeartifact_not_pruned",
915+
actual = ":tar23",
916+
expected = [],
917+
)
918+
919+
#############
920+
# Theory 1 targets: used by the CI cache-invalidation test.
921+
#
922+
# whl_install_from_file reads whl_module.py so the CI step can change the file's
923+
# content between two `bazel build` invocations (using --disk_cache) and verify
924+
# that the archive is rebuilt. If whl_install is incorrectly pruned from the tar
925+
# action's cache key, the archive will be stale after the content change.
926+
927+
whl_install_from_file(
928+
name = "theory1_whl_install",
929+
srcs = ["whl_module.py"],
930+
tags = ["manual"],
931+
)
932+
933+
# venv_binary_independent: the venv action does NOT depend on whl_install at
934+
# the Bazel level, so changing whl_module.py leaves the venv digest unchanged.
935+
# Only theory1_whl_install changes, which is exactly what we need: if
936+
# whl_install is incorrectly in unused_inputs_list, the tar's reduced cache
937+
# key is identical to the previous build and Bazel serves a stale archive.
938+
venv_binary_independent(
939+
name = "theory1_venv_binary",
940+
whl_name = "theory1_whl_install",
941+
tags = ["manual"],
942+
)
943+
944+
mtree_spec(
945+
name = "mtree_theory1",
946+
srcs = [":theory1_venv_binary"],
947+
tags = ["manual"],
948+
)
949+
950+
tar(
951+
name = "tar_theory1",
952+
srcs = [
953+
":theory1_venv_binary",
954+
":theory1_whl_install", # needed by bsdtar via symlink dereference, not in mtree
955+
],
956+
out = "theory1.tar",
957+
compute_unused_inputs = 1,
958+
mtree = ":mtree_theory1",
959+
tags = ["manual"],
960+
)

tar/tests/cross_linking_tree.bzl

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
"""
2+
Fixtures that model the py_console_script_binary / venv→whl_install pattern.
3+
4+
Two rules are exported:
5+
6+
whl_install — a standalone TreeArtifact containing real files.
7+
8+
venv_binary — an executable whose runfiles contain a *_venv TreeArtifact with
9+
file-level RELATIVE symlinks into the whl_install TreeArtifact.
10+
Critically, whl_install is NOT placed in venv_binary's runfiles;
11+
it is only needed by the binary's action so the symlinks are valid.
12+
13+
Usage in tests:
14+
15+
whl_install(name = "my_whl")
16+
venv_binary(name = "my_venv_bin", whl_install = ":my_whl")
17+
18+
# whl_install must be a separate tar src so it is available to bsdtar
19+
# (venv symlinks into it), but it is not covered by the mtree because the
20+
# mtree is derived only from venv_binary's runfiles. The pruner therefore
21+
# marks whl_install files as unused even though bsdtar needs them to
22+
# dereference the symlinks inside venv.
23+
tar(
24+
name = "my_tar",
25+
srcs = [":my_venv_bin", ":my_whl"],
26+
mtree = mtree_spec([":my_venv_bin"]),
27+
compute_unused_inputs = 1,
28+
)
29+
"""
30+
31+
def _whl_install_impl(ctx):
32+
tree = ctx.actions.declare_directory(ctx.label.name)
33+
ctx.actions.run_shell(
34+
outputs = [tree],
35+
command = "echo 'module' > {d}/module.py".format(d = tree.path),
36+
)
37+
return [DefaultInfo(files = depset([tree]))]
38+
39+
whl_install = rule(implementation = _whl_install_impl)
40+
41+
def _whl_install_from_file_impl(ctx):
42+
"""Like whl_install but copies content from a source file.
43+
44+
Allows CI to change the whl content between builds (same TreeArtifact
45+
structure, different file content) to verify cache-invalidation behaviour.
46+
"""
47+
tree = ctx.actions.declare_directory(ctx.label.name)
48+
src = ctx.files.srcs[0]
49+
ctx.actions.run_shell(
50+
inputs = [src],
51+
outputs = [tree],
52+
command = "cp {src} {d}/module.py".format(src = src.path, d = tree.path),
53+
)
54+
return [DefaultInfo(files = depset([tree]))]
55+
56+
whl_install_from_file = rule(
57+
implementation = _whl_install_from_file_impl,
58+
attrs = {"srcs": attr.label_list(allow_files = True, mandatory = True)},
59+
)
60+
61+
def _venv_binary_impl(ctx):
62+
executable = ctx.actions.declare_file(ctx.label.name)
63+
ctx.actions.write(executable, "#!/bin/bash\necho hello", is_executable = True)
64+
65+
whl = ctx.file.whl_install
66+
67+
# Build a relative symlink path from inside venv/lib/ up to the whl_install
68+
# sibling directory. The two TreeArtifacts sit side-by-side in the output
69+
# tree, so ../../<whl_basename>/module.py resolves correctly.
70+
whl_basename = whl.path.split("/")[-1]
71+
venv = ctx.actions.declare_directory(ctx.label.name + "_venv")
72+
ctx.actions.run_shell(
73+
inputs = [whl],
74+
outputs = [venv],
75+
command = "mkdir -p {v}/lib && ln -s ../../{whl}/module.py {v}/lib/module.py".format(
76+
v = venv.path,
77+
whl = whl_basename,
78+
),
79+
)
80+
81+
# Only venv goes into runfiles — whl_install is intentionally omitted so
82+
# that the mtree (derived from this binary's runfiles) does not contain any
83+
# content= path that names a whl_install file directly.
84+
return [DefaultInfo(
85+
executable = executable,
86+
runfiles = ctx.runfiles(files = [venv]),
87+
)]
88+
89+
venv_binary = rule(
90+
implementation = _venv_binary_impl,
91+
attrs = {
92+
"whl_install": attr.label(allow_single_file = True),
93+
},
94+
executable = True,
95+
)
96+
97+
def _venv_binary_independent_impl(ctx):
98+
"""Like venv_binary but the venv action does NOT depend on whl_install.
99+
100+
The venv receives the whl_install NAME as a string attribute so the action
101+
command is fully determined at analysis time without any file input. This
102+
means that when whl_install content changes, the venv action's cache key
103+
(command + empty inputs) is unchanged, so the venv stays cached and its
104+
digest is unchanged. Only the tar action's whl_install input changes,
105+
which is exactly the condition needed to demonstrate the stale disk-cache
106+
entry: if whl_install is incorrectly in unused_inputs_list, the tar's
107+
reduced cache key (excluding whl_install) matches the previous entry and
108+
Bazel serves a stale archive even though the whl content changed.
109+
"""
110+
executable = ctx.actions.declare_file(ctx.label.name)
111+
ctx.actions.write(executable, "#!/bin/bash\necho hello", is_executable = True)
112+
113+
venv = ctx.actions.declare_directory(ctx.label.name + "_venv")
114+
ctx.actions.run_shell(
115+
# No inputs declared — the venv action must stay cached when whl changes.
116+
inputs = [],
117+
outputs = [venv],
118+
command = "mkdir -p {v}/lib && ln -sf ../../{whl}/module.py {v}/lib/module.py".format(
119+
v = venv.path,
120+
whl = ctx.attr.whl_name,
121+
),
122+
)
123+
124+
return [DefaultInfo(
125+
executable = executable,
126+
runfiles = ctx.runfiles(files = [venv]),
127+
)]
128+
129+
venv_binary_independent = rule(
130+
implementation = _venv_binary_independent_impl,
131+
# whl_name is just the directory basename string so the action has no file
132+
# dependency on whl_install and its cache key remains stable across whl changes.
133+
attrs = {"whl_name": attr.string(mandatory = True)},
134+
executable = True,
135+
)

tar/tests/whl_module.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
# whl content v1

0 commit comments

Comments
 (0)