Skip to content

Commit 84caacf

Browse files
committed
go: use persistent named cache for GOMODCACHE
Previously every sandbox received a fresh GOMODCACHE, causing all third-party modules to be re-downloaded from the network on every cold build. This makes third-party module downloads use the `go_mod_cache` named cache as an accelerator: the download process writes modules into the shared cache and copies them into a sandbox-local `gopath/pkg/mod` tree before capture. Captured digests remain the source of truth, so results are byte-identical whether the cache is warm or cold. Key design choices: - Download and copy happen in a single process (the `__PANTS_GO_FETCH_MODULE` mode in `__run_go.sh`) to satisfy the self-healing invariant: if the named cache is wiped between steps, the process that captures `gopath/` must itself be able to re-populate it. A two-process shape would violate this invariant. - GOMODCACHE is set to `__gomodcache` (sibling of `gopath/`, outside the captured tree) when `use_module_cache=True`. `output_directories=("gopath",)` can therefore never accidentally walk into the shared cache. - `-modcacherw` is injected via `GOFLAGS` so Pants can prune or clear the named cache without `rm: cannot remove ... Permission denied` errors. - Only the two `allow_downloads=True` processes in `third_party_pkg.py` get the named cache. Compile/link/vet processes run `GOPROXY=off` against captured digests and must not see a shared cache that could silently satisfy a missing input. - The fetch mode uses only shell builtins and /bin tools (no grep/sed/PATH assumptions), emits the module metadata on stdout and propagates the exit code of `go mod download`, so failures surface through the normal fallible-process path and the engine never materializes the module tree just to read the metadata. - Remote execution without `--remote-execution-append-only-caches-base-path` degrades gracefully: the cache mount is absent, `mkdir -p $GOMODCACHE` creates a plain sandbox-local directory, and behavior is identical to today. - `ModuleDescriptors.go_mods_digest` is removed (zero consumers, verified). One-time invalidation: the `__run_go.sh` script change invalidates all previously cached Go process results. See #13390.
1 parent 8cf8692 commit 84caacf

4 files changed

Lines changed: 269 additions & 14 deletions

File tree

docs/notes/2.33.x.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,8 @@ Fixes JavaScript workspace builds where dependencies are installed under members
8787

8888
Third-party module analysis is now deduplicated across `go.mod` files. Previously, a module required by `N` `go.mod` files was downloaded and analyzed `N` times, which caused significant memory and time overhead in monorepos with many overlapping `go.mod` files. On a 3-`go.mod` reproducer, `pants list ::` peak memory dropped from 91 GB to 32 GB (-65%). This is a no-op for repos with a single `go.mod`. See [#20274](https://github.com/pantsbuild/pants/issues/20274).
8989

90+
Third-party Go module downloads now use a persistent named cache (`go_mod_cache`) for `GOMODCACHE`. Previously every sandbox got a fresh module cache, so any `go.mod`/`go.sum` change caused all modules to be re-downloaded from the network. With the named cache, subsequent builds reuse locally cached module zips even when the engine's content-addressed store is cold. Captured digests remain the source of truth: modules are copied out of the shared cache into the sandbox before capture, so results are byte-identical regardless of whether the cache is warm or cold. Remote-execution users: the named cache is only effective when `--remote-execution-append-only-caches-base-path` is configured; without it the process falls back silently to a per-sandbox download (today's behavior). Note: this change invalidates all previously cached Go process results (one-time invalidation). See [#13390](https://github.com/pantsbuild/pants/issues/13390).
91+
9092
### Plugin API changes
9193

9294
`FrozenOrderedSet` is now backed by a native Rust implementation and is built in `__new__` rather than `__init__`. Subclasses that customized the set's contents by overriding `__init__` (for example, to transform the input iterable before constructing) must move that logic to `__new__`, since the set is already built and immutable by the time `__init__` runs.

src/python/pants/backend/go/util_rules/sdk.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ class GoSdkProcess:
3838
output_directories: tuple[str, ...]
3939
replace_sandbox_root_in_args: bool
4040

41+
use_module_cache: bool
42+
4143
def __init__(
4244
self,
4345
command: Iterable[str],
@@ -50,6 +52,7 @@ def __init__(
5052
output_directories: Iterable[str] = (),
5153
allow_downloads: bool = False,
5254
replace_sandbox_root_in_args: bool = False,
55+
use_module_cache: bool = False,
5356
) -> None:
5457
object.__setattr__(self, "command", tuple(command))
5558
object.__setattr__(self, "description", description)
@@ -67,6 +70,7 @@ def __init__(
6770
object.__setattr__(self, "output_files", tuple(output_files))
6871
object.__setattr__(self, "output_directories", tuple(output_directories))
6972
object.__setattr__(self, "replace_sandbox_root_in_args", replace_sandbox_root_in_args)
73+
object.__setattr__(self, "use_module_cache", use_module_cache)
7074

7175

7276
@dataclass(frozen=True)
@@ -76,6 +80,7 @@ class GoSdkRunSetup:
7680

7781
CHDIR_ENV = "__PANTS_CHDIR_TO"
7882
SANDBOX_ROOT_ENV = "__PANTS_REPLACE_SANDBOX_ROOT"
83+
FETCH_MODULE_ENV = "__PANTS_GO_FETCH_MODULE"
7984

8085

8186
@rule
@@ -92,6 +97,43 @@ async def go_sdk_invoke_setup(goroot: GoRoot) -> GoSdkRunSetup:
9297
export GOPATH="${{sandbox_root}}/gopath"
9398
export GOCACHE="${{sandbox_root}}/cache"
9499
/bin/mkdir -p "$GOPATH" "$GOCACHE"
100+
if [ -n "$__PANTS_GO_MODCACHE" ]; then
101+
export GOMODCACHE="${{sandbox_root}}/__gomodcache"
102+
export GOFLAGS="${{GOFLAGS:+${{GOFLAGS}} }}-modcacherw"
103+
/bin/mkdir -p "$GOMODCACHE"
104+
fi
105+
if [ -n "$__PANTS_GO_FETCH_MODULE" ]; then
106+
module_version="$__PANTS_GO_FETCH_MODULE"
107+
"{goroot.path}/bin/go" mod download -json "$module_version" > __module_metadata.json
108+
download_status=$?
109+
/bin/cat __module_metadata.json
110+
if [ "$download_status" -ne 0 ]; then
111+
exit "$download_status"
112+
fi
113+
# Parse the Dir/GoMod paths from the metadata with shell string operations only:
114+
# the sandbox PATH cannot be assumed to provide grep/sed.
115+
dir=""
116+
gomod=""
117+
while IFS= read -r line; do
118+
case "$line" in
119+
*'"Dir": "'*) if [ -z "$dir" ]; then dir=${{line#*'"Dir": "'}}; dir=${{dir%'"'*}}; fi ;;
120+
*'"GoMod": "'*) if [ -z "$gomod" ]; then gomod=${{line#*'"GoMod": "'}}; gomod=${{gomod%'"'*}}; fi ;;
121+
esac
122+
done < __module_metadata.json
123+
marker="__gomodcache/"
124+
dir_rel="${{dir#*${{marker}}}}"
125+
gomod_rel="${{gomod#*${{marker}}}}"
126+
if [ -z "$dir" ] || [ -z "$gomod" ] || [ "$dir_rel" = "$dir" ] || [ "$gomod_rel" = "$gomod" ]; then
127+
echo "Failed to locate module $module_version under GOMODCACHE after download." 1>&2
128+
exit 1
129+
fi
130+
dest_dir="$GOPATH/pkg/mod/$dir_rel"
131+
dest_gomod="$GOPATH/pkg/mod/$gomod_rel"
132+
/bin/mkdir -p "$dest_dir" "${{dest_gomod%/*}}" || exit 1
133+
/bin/cp -Rp "$dir/." "$dest_dir/" || exit 1
134+
/bin/cp -p "$gomod" "$dest_gomod" || exit 1
135+
exit 0
136+
fi
95137
if [ -n "${GoSdkRunSetup.CHDIR_ENV}" ]; then
96138
cd "${GoSdkRunSetup.CHDIR_ENV}"
97139
fi
@@ -158,6 +200,11 @@ async def setup_go_sdk_process(
158200
if request.replace_sandbox_root_in_args:
159201
env[GoSdkRunSetup.SANDBOX_ROOT_ENV] = "1"
160202

203+
append_only_caches: dict[str, str] = {}
204+
if request.use_module_cache:
205+
env["__PANTS_GO_MODCACHE"] = "1"
206+
append_only_caches["go_mod_cache"] = "__gomodcache"
207+
161208
# Disable the "coverage redesign" experiment on Go v1.20+ for now since Pants does not yet support it.
162209
if goroot.is_compatible_version("1.20") and not goroot.is_compatible_version("1.25"):
163210
exp_str = env.get("GOEXPERIMENT", "")
@@ -175,6 +222,7 @@ async def setup_go_sdk_process(
175222
description=request.description,
176223
output_files=request.output_files,
177224
output_directories=request.output_directories,
225+
append_only_caches=append_only_caches,
178226
level=LogLevel.DEBUG,
179227
)
180228

src/python/pants/backend/go/util_rules/third_party_pkg.py

Lines changed: 30 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -114,11 +114,12 @@ def debug_hint(self) -> str:
114114

115115
@dataclass(frozen=True)
116116
class AllThirdPartyPackages:
117-
"""All the packages downloaded from a go.mod, along with a digest of the downloaded files.
117+
"""All the packages downloaded from a go.mod.
118118
119-
The digest has files in the format `gopath/pkg/mod`, which is what `GoSdkProcess` sets `GOPATH`
120-
to. This means that you can include the digest in a process and Go will properly consume it as
121-
the `GOPATH`.
119+
Each ``ThirdPartyPkgAnalysis`` carries its own per-package digest with files
120+
in ``gopath/pkg/mod/<module@version>/...`` layout (the captured subset from the
121+
download sandbox). Consumers merge whichever package digests they need into
122+
their own input digest.
122123
"""
123124

124125
digest: Digest
@@ -151,7 +152,6 @@ class ModuleDescriptor:
151152
@dataclass(frozen=True)
152153
class ModuleDescriptors:
153154
modules: FrozenOrderedSet[ModuleDescriptor]
154-
go_mods_digest: Digest
155155

156156

157157
@dataclass(frozen=True)
@@ -232,17 +232,17 @@ async def analyze_module_dependencies(request: ModuleDescriptorsRequest) -> Modu
232232
GoSdkProcess(
233233
command=["list", "-mod=readonly", "-e", "-m", "-json", "all"],
234234
input_digest=request.digest,
235-
output_directories=("gopath",),
236235
working_dir=request.path if request.path else None,
237236
# Allow downloads of the module metadata (i.e., go.mod files).
238237
allow_downloads=True,
238+
use_module_cache=True,
239239
description="Analyze Go module dependencies.",
240240
)
241241
)
242242
)
243243

244244
if len(mod_list_result.stdout) == 0:
245-
return ModuleDescriptors(FrozenOrderedSet(), EMPTY_DIGEST)
245+
return ModuleDescriptors(FrozenOrderedSet())
246246

247247
descriptors: dict[tuple[str, str], ModuleDescriptor] = {}
248248

@@ -276,7 +276,7 @@ async def analyze_module_dependencies(request: ModuleDescriptorsRequest) -> Modu
276276
# Gazelle does this, mainly to store the sum on the go_repository rule. We could store it (or its
277277
# absence) to be able to download sums automatically.
278278

279-
return ModuleDescriptors(FrozenOrderedSet(descriptors.values()), mod_list_result.output_digest)
279+
return ModuleDescriptors(FrozenOrderedSet(descriptors.values()))
280280

281281

282282
def strip_sandbox_prefix(path: str, marker: str) -> str:
@@ -529,12 +529,20 @@ async def download_and_analyze_module(
529529
synthetic_files.append(FileContent("go.sum", synthetic_go_sum.encode()))
530530
synthetic_digest = await create_digest(CreateDigest(synthetic_files))
531531

532+
# Fetch the module into the named cache and copy to gopath/pkg/mod in one process.
533+
# The __PANTS_GO_FETCH_MODULE mode in __run_go.sh handles both steps atomically:
534+
# it runs `go mod download -json`, emits the metadata on stdout, then copies the
535+
# extracted tree from __gomodcache into gopath/pkg/mod. This avoids any race
536+
# between the download (process 1) and copy (process 2) that would violate the
537+
# self-healing invariant if the named cache were wiped between steps.
532538
download_result = await fallible_to_exec_result_or_raise(
533539
**implicitly(
534540
GoSdkProcess(
535-
("mod", "download", "-json", f"{request.name}@{request.version}"),
541+
(),
542+
env={"__PANTS_GO_FETCH_MODULE": f"{request.name}@{request.version}"},
536543
input_digest=synthetic_digest,
537544
allow_downloads=True,
545+
use_module_cache=True,
538546
output_directories=("gopath",),
539547
description=f"Download Go module {request.name}@{request.version}.",
540548
)
@@ -543,12 +551,20 @@ async def download_and_analyze_module(
543551

544552
if len(download_result.stdout) == 0:
545553
raise AssertionError(
546-
f"Expected output from `go mod download` for {request.name}@{request.version}."
554+
f"Expected metadata output from module fetch for {request.name}@{request.version}."
547555
)
548-
549-
module_metadata = json.loads(download_result.stdout)
550-
module_sources_relpath = strip_sandbox_prefix(module_metadata["Dir"], "gopath/")
551-
go_mod_relpath = strip_sandbox_prefix(module_metadata["GoMod"], "gopath/")
556+
module_metadata_json = json.loads(download_result.stdout)
557+
558+
# Dir/GoMod point into __gomodcache (absolute sandbox paths). Extract the
559+
# portion after __gomodcache/ to get the relative path within the cache tree,
560+
# then re-prefix with gopath/pkg/mod/ to match where the copy step placed them.
561+
# strip_sandbox_prefix returns path[marker_pos:] (includes the marker), so we
562+
# strip the marker length to get just the relative tail.
563+
_modcache_marker = "__gomodcache/"
564+
_dir_from_marker = strip_sandbox_prefix(module_metadata_json["Dir"], _modcache_marker)
565+
_gomod_from_marker = strip_sandbox_prefix(module_metadata_json["GoMod"], _modcache_marker)
566+
module_sources_relpath = "gopath/pkg/mod/" + _dir_from_marker[len(_modcache_marker) :]
567+
go_mod_relpath = "gopath/pkg/mod/" + _gomod_from_marker[len(_modcache_marker) :]
552568

553569
module_sources_snapshot = await digest_to_snapshot(
554570
**implicitly(

0 commit comments

Comments
 (0)