Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
496cc84
feat(targethasher): collapse bzlmod external targets to repo-level hash
yushan8 Sep 10, 2026
986eea4
Fix collapseBzlmodExternalTargets to respect excluded_files regex
yushan8 Sep 10, 2026
e46077c
Consolidate bzlmod/WORKSPACE external handling into else block and ad…
yushan8 Sep 10, 2026
f419ccf
Use Bazel marker file hashes for bzlmod external target collapsing
yushan8 Sep 10, 2026
fedc0e2
Return error when marker files can't be read instead of silent fallback
yushan8 Sep 10, 2026
b765037
Address Uber Go style guide issues
yushan8 Sep 10, 2026
4b26933
Pass OutputBase via NativeGraphRunnerParams instead of Bazel interface
yushan8 Sep 10, 2026
c761c91
Require OutputBase for bzlmod repos instead of silently skipping
yushan8 Sep 10, 2026
6cf661d
Simplify marker reading: hash the whole file instead of parsing hex
yushan8 Sep 10, 2026
81c3dba
Hash only the first line of marker files
yushan8 Sep 10, 2026
4528d39
Move OutputBase to Bazel interface, resolve after query
yushan8 Sep 11, 2026
d1c7440
Extract shouldCollapse helper, update copyright year
yushan8 Sep 11, 2026
5ac76f3
Fix documentation to match current implementation
yushan8 Sep 11, 2026
439fc7b
Guard against nil targets, empty output_base, and invalid inputs
yushan8 Sep 11, 2026
908d969
Move markers.go to core/targethasher, revert Bazel interface change
yushan8 Sep 11, 2026
b5d6608
Move bazelOutputBase to core/bazel as exported OutputBase
yushan8 Sep 11, 2026
6ac21c2
Remove trailing blank lines to fix lint
yushan8 Sep 11, 2026
c81f9f5
Unexport detectBazelExecutable, now only used within core/bazel
yushan8 Sep 11, 2026
3b8841e
Fix detectBazelExecutable comment to say bazelisk
yushan8 Sep 11, 2026
393c771
Add test for bzlmod collapse path in graphrunner
yushan8 Sep 11, 2026
7791cee
Hash full marker file (excluding ENV lines) for patch correctness
yushan8 Sep 11, 2026
0d1cf05
Address PR review comments
yushan8 Sep 11, 2026
9241589
Error when bzlmod repo has targets but no marker file
yushan8 Sep 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions core/bazel/bazel.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import (
"os"
"path/filepath"
"runtime"
"strings"
"time"

buildpb "github.com/bazelbuild/buildtools/build_proto"
Expand Down Expand Up @@ -182,3 +183,23 @@ func ensureBazelisk(ctx context.Context) (_ string, retErr error) {
}
return dest, nil
}

// OutputBase resolves the bazel executable and runs `bazel info output_base`,
// returning the absolute path to the output base directory.
func OutputBase(ctx context.Context, workspacePath, bazelCommand string) (string, error) {
resolved, err := detectBazelExecutable(ctx, bazelCommand)
if err != nil {
return "", fmt.Errorf("detect bazel: %w", err)
}
cmd := execcmd.CommandContext(ctx, resolved, "info", "output_base")
cmd.Dir = workspacePath
out, err := cmd.Output()
if err != nil {
return "", fmt.Errorf("bazel info output_base: %w", err)
}
result := strings.TrimSpace(string(out))
if result == "" {
return "", fmt.Errorf("bazel info output_base returned empty path")
}
return result, nil
}
2 changes: 2 additions & 0 deletions core/targethasher/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,13 @@ go_library(
name = "targethasher",
srcs = [
"graph.go",
"markers.go",
"sourcehasher.go",
],
importpath = "github.com/uber/tango/core/targethasher",
visibility = ["//visibility:public"],
deps = [
"//core/bazel",
"@com_github_bazelbuild_buildtools//build_proto",
"@com_github_bazelbuild_buildtools//labels",
"@com_github_deckarep_golang_set_v2//:golang-set",
Expand Down
108 changes: 103 additions & 5 deletions core/targethasher/graph.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,13 @@ type HashConfig struct {
// AllTargetsFiles lists repo-relative paths whose hashes should be
// extracted from KnownSourceHashes into Result.AllTargetsFileHashes.
AllTargetsFiles []string
// RepoMarkerHashes maps canonical bzlmod repo names to repo rule input
// hashes read from Bazel's marker files ($(output_base)/external/@repo.marker).
// Used by HashExternalTargetsBzlmod to pre-hash external source files
// without reading their content from disk. The marker hash changes on any
// dependency upgrade, so repos whose canonical name stays the same across
// versions (e.g. "protobuf+") are still correctly detected as changed.
RepoMarkerHashes map[string][]byte
}

// Target contains information about the hash for a single target
Expand Down Expand Up @@ -155,7 +162,7 @@ func FromProto(ctx context.Context, r *buildpb.QueryResult, workspaceroot string
result, err := fromProto(ctx, r, &diskHashHelper{
workspaceroot: workspaceroot,
knownFileHashes: hashConfig.KnownSourceHashes,
}, workspaceroot, fullHashRepos, set.NewSet(hashConfig.SequentialHashTargets...), excludedRegex, hashConfig.UseBzlmod)
}, workspaceroot, fullHashRepos, set.NewSet(hashConfig.SequentialHashTargets...), excludedRegex, hashConfig.UseBzlmod, hashConfig.RepoMarkerHashes)
if err != nil {
return result, err
}
Expand All @@ -176,7 +183,7 @@ func FromProto(ctx context.Context, r *buildpb.QueryResult, workspaceroot string

// FromProtoNoHash calculates a DAG graph based on a query result. It does not calculate hashes for targets.
func FromProtoNoHash(ctx context.Context, r *buildpb.QueryResult) (Result, error) {
return fromProto(ctx, r, &noOpHasher{}, "", set.NewSet[string](), set.NewSet[string](), nil, false)
return fromProto(ctx, r, &noOpHasher{}, "", set.NewSet[string](), set.NewSet[string](), nil, false, nil)
}

// for external targets, url and urls attributes could cause non-deterministic hash values,
Expand Down Expand Up @@ -386,6 +393,88 @@ func HashExternalTargets(ctx context.Context, r *buildpb.QueryResult, targets ma
return nil
}

// bzlmodRepoName extracts the canonical bzlmod repo name from a target label.
// For "@@rules_python++pip+foo//pkg:target" it returns "rules_python++pip+foo".
// Returns "" for non-bzlmod targets.
func bzlmodRepoName(targetName string) string {
if !strings.HasPrefix(targetName, "@@") {
return ""
}
// Strip leading "@@", then find "//" separator.
rest := targetName[2:]
idx := strings.Index(rest, "//")
if idx <= 0 {
return ""
}
return rest[:idx]
}

// shouldCollapseToBzlmodRepo reports whether a bzlmod external target should
// be pre-hashed using the repo's marker file hash instead of hashing its
// file content during the DFS. Only source and generated files are collapsed;
// rule targets are left alone so dependency edges are preserved.
func shouldCollapseToBzlmodRepo(target *Target, repo string, fullHashRepos set.Set[string], excludedRegex []*regexp.Regexp) bool {
if target == nil {
return false
}
if repo == "" || fullHashRepos.Contains(repo) {
return false
}
if target.RuleType != SourceFileType && target.RuleType != GeneratedFileType {
return false
}
if target.Hash != nil {
return false
}
if isExcluded(target.Name, excludedRegex) {
return false
}
return true
}

// HashExternalTargetsBzlmod pre-hashes bzlmod external source and
// generated file targets using hashes derived from Bazel's marker files.
// This is the bzlmod equivalent of legacy WORKSPACE HashExternalTargets.
//
// Marker files track both the repo rule's declarative inputs (version,
// URL, integrity) and per-file SHA-256 content hashes for local patches
// applied via single_version_override. readMarkerHash hashes all stable
// lines (skipping ENV) so the collapsed hash changes on dependency
// upgrades AND patch content modifications.
//
// Every external repo referenced in the query result should have a
// marker file after bazel query completes. Returns an error if a
// collapsible repo is missing its marker.
func HashExternalTargetsBzlmod(targets map[string]*Target, fullHashRepos set.Set[string], excludedRegex []*regexp.Regexp, repoMarkerHashes map[string][]byte) error {
if len(repoMarkerHashes) == 0 {
return nil
}

repoHashes := make(map[string][]byte)
for _, target := range targets {
repo := bzlmodRepoName(target.Name)
if !shouldCollapseToBzlmodRepo(target, repo, fullHashRepos, excludedRegex) {
continue
}

h, ok := repoHashes[repo]
if !ok {
markerHash, hasMarker := repoMarkerHashes[repo]
if !hasMarker || len(markerHash) == 0 {
return fmt.Errorf("bzlmod repo %q has targets in query but no marker file", repo)
}
rh := newHash()
rh.Write(markerHash)
h = rh.Sum(nil)
repoHashes[repo] = h
}

target.Hash = h
target.HashWithoutDeps = h
}
return nil
}

// GetTopologicalRootsAndIdentifyBuildableRoots returns a list of topological roots and marks buildable roots in the target graph
func GetTopologicalRootsAndIdentifyBuildableRoots(targets map[string]*Target) []string {
// get targets that cannot be root, i.e. dependencies of some other targets
Expand Down Expand Up @@ -419,21 +508,30 @@ func GetTopologicalRootsAndIdentifyBuildableRoots(targets map[string]*Target) []
return roots
}

func fromProto(ctx context.Context, r *buildpb.QueryResult, hasher SourceHasher, workspaceroot string, fullHashRepos set.Set[string], sequentialHashTargets set.Set[string], excludedRegex []*regexp.Regexp, useBzlmod bool) (Result, error) {
func fromProto(ctx context.Context, r *buildpb.QueryResult, hasher SourceHasher, workspaceroot string, fullHashRepos set.Set[string], sequentialHashTargets set.Set[string], excludedRegex []*regexp.Regexp, useBzlmod bool, repoMarkerHashes map[string][]byte) (Result, error) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

next PR, let's make these params a struct

warns := make(map[string]error)
// Build target graph with dependencies, but without hash and root information.
targets, err := GetInternalTargetsWithoutHashAndRootInfo(ctx, r)
if err != nil {
return EmptyResult(), err
}

// add external rule targets (//external:*) to the same map and hash them
// no need for bzlmod because there's no //external:* rules, we will hash external source as is
if !useBzlmod {
// Legacy WORKSPACE: add external rule targets (//external:*) to the
// map and hash them. No //external:* rules exist under bzlmod.
if err := HashExternalTargets(ctx, r, targets, hasher, workspaceroot, fullHashRepos, warns, useBzlmod); err != nil {
return EmptyResult(), err
}
} else {
// Bzlmod: collapse external source/generated file targets using
// Bazel marker file hashes. Avoids visiting millions of individual
// pip-wheel files during the DFS — the bzlmod equivalent of legacy
// WORKSPACE //external:repo collapsing.
if err := HashExternalTargetsBzlmod(targets, fullHashRepos, excludedRegex, repoMarkerHashes); err != nil {
return EmptyResult(), err
}
}

// get topological roots and update buildable roots info
roots := GetTopologicalRootsAndIdentifyBuildableRoots(targets)

Expand Down
Loading
Loading