From 69a0ab4d228919a82b2d2b92f060538d51c38811 Mon Sep 17 00:00:00 2001 From: Hongxin Liang Date: Tue, 22 Sep 2026 20:09:12 +0200 Subject: [PATCH 01/15] perf(hash-persister): probe dirty packages before rdeps propagation When a BUILD.bazel changes, all targets in the package are marked dirty and their reverse dependencies cascade through the graph. For high-fanout packages like tools/binaries (which contains widely-used aliases), adding a single new target inflates the dirty set to hundreds of thousands of targets even though existing targets are unchanged. Add a probe phase to runSeeded that queries only the dirty packages, hashes those targets against seed dependency hashes, and compares with the seed. Only targets whose hash actually changed propagate to reverse dependencies. This dramatically reduces the scoped query size when BUILD.bazel changes don't affect most existing targets. Co-Authored-By: Claude Opus 4.6 (1M context) --- hash-persister/hash-persister.go | 118 ++++++++++++++++++++++++++-- pkg/dirty_set.go | 113 +++++++++++++++++++++++++++ pkg/dirty_set_test.go | 128 +++++++++++++++++++++++++++++++ 3 files changed, 354 insertions(+), 5 deletions(-) diff --git a/hash-persister/hash-persister.go b/hash-persister/hash-persister.go index 95182bb..c83b304 100644 --- a/hash-persister/hash-persister.go +++ b/hash-persister/hash-persister.go @@ -281,6 +281,30 @@ func runSeeded(cfg *config) (seededOutcome, error) { return outcome, nil } + commitRev, err := pkg.NewLabelledGitRev(cfg.Context.WorkspacePath, cfg.CommitSha, "commit") + if err != nil { + return seededOutcome{}, fmt.Errorf("failed to resolve commit %s: %w", cfg.CommitSha, err) + } + + // Phase: probe dirty packages to prune false-positive rdeps. + // + // When a BUILD.bazel changes, all targets in the package are marked + // dirty and their rdeps cascade through the graph. But most existing + // targets are often unchanged (e.g. a new sibling was added). A small + // probe query on just the dirty packages lets us compare hashes against + // the seed and only propagate rdeps from targets that actually changed. + unprunedDirtyStarCount := len(dirtyResult.DirtyStarLabels) + if unprunedDirtyStarCount > len(dirtyResult.DirtyLabels) { + dirtyResult, err = probePruneDirtySet(cfg, commitRev, dirtyResult, seedData) + if err != nil { + log.Printf("Probe pruning failed, continuing with unpruned dirty set: %v", err) + } else { + log.Printf("Probe pruning: %d dirty* -> %d dirty* (%d eliminated)", + unprunedDirtyStarCount, len(dirtyResult.DirtyStarLabels), + unprunedDirtyStarCount-len(dirtyResult.DirtyStarLabels)) + } + } + estimatedRecomputedTargets := countDirtySeedTargets(seedData.TargetHashes, dirtyResult.DirtyStarLabels) seedTargetCount := len(seedData.TargetHashes) if shouldFallbackForRecomputation( @@ -320,11 +344,6 @@ func runSeeded(cfg *config) (seededOutcome, error) { return fallback("unscopable_target_pattern", fmt.Sprintf("cannot scope targets pattern: %v", err)) } - commitRev, err := pkg.NewLabelledGitRev(cfg.Context.WorkspacePath, cfg.CommitSha, "commit") - if err != nil { - return seededOutcome{}, fmt.Errorf("failed to resolve commit %s: %w", cfg.CommitSha, err) - } - phaseStart = time.Now() scopedTargets, err := pkg.ParseTargetsList(scopedPattern) if err != nil { @@ -384,6 +403,95 @@ func runSeeded(cfg *config) (seededOutcome, error) { return outcome, nil } +// probePruneDirtySet runs a small probe query on just the dirty packages, +// hashes those targets with seed hashes for their dependencies, and returns +// a pruned DirtySetResult where rdeps are only propagated from targets +// whose hash actually changed. +func probePruneDirtySet( + cfg *config, + commitRev pkg.LabelledGitRev, + dirtyResult *pkg.DirtySetResult, + seedData *pkg.PersistedHashData, +) (*pkg.DirtySetResult, error) { + phaseStart := time.Now() + + probeHashes, err := probePackageHashes(cfg, commitRev, dirtyResult.DirtyPackages, seedData) + if err != nil { + return nil, err + } + log.Printf("Phase probe completed in %v (%d targets in %d packages)", + time.Since(phaseStart), len(probeHashes), len(dirtyResult.DirtyPackages)) + + return pkg.PruneDirtySet(dirtyResult, seedData.TargetHashes, seedData.TargetEdges, probeHashes), nil +} + +// probePackageHashes queries and hashes targets in the given packages, +// seeding dependency hashes from the seed file so that only the dirty +// packages need a Bazel query. +func probePackageHashes( + cfg *config, + commitRev pkg.LabelledGitRev, + dirtyPackages []string, + seedData *pkg.PersistedHashData, +) (map[string]string, error) { + probeUniverse := pkg.BuildScopedUniverse(dirtyPackages, nil) + probePattern, err := pkg.ScopeTargetsPattern(cfg.Targets.String(), probeUniverse) + if err != nil { + return nil, fmt.Errorf("cannot scope probe pattern: %w", err) + } + probeTargets, err := pkg.ParseTargetsList(probePattern) + if err != nil { + return nil, fmt.Errorf("failed to parse probe targets: %w", err) + } + + probeResults, probeCleanup, err := pkg.LoadIncompleteMetadata(cfg.Context, commitRev, probeTargets) + if err != nil { + probeCleanup() + return nil, fmt.Errorf("probe query failed: %w", err) + } + defer probeCleanup() + + probeSeedHashes, err := buildExternalSeedHashes(seedData, dirtyPackages) + if err != nil { + return nil, err + } + if err := probeResults.TargetHashCache.SeedHashes(probeSeedHashes); err != nil { + return nil, fmt.Errorf("cannot seed probe hashes: %w", err) + } + + if err := probeResults.PrefillCache(); err != nil { + return nil, fmt.Errorf("probe hashing failed: %w", err) + } + + return pkg.ProbeHashesFromQueryResults(probeResults) +} + +// buildExternalSeedHashes collects seed hashes for all targets NOT in the +// given packages, so that dependency hashes resolve without a full query. +func buildExternalSeedHashes( + seedData *pkg.PersistedHashData, + dirtyPackages []string, +) (map[string][]byte, error) { + dirtyPkgSet := make(map[string]bool, len(dirtyPackages)) + for _, p := range dirtyPackages { + dirtyPkgSet[p] = true + } + hashes := make(map[string][]byte) + for label, configMap := range seedData.TargetHashes { + if dirtyPkgSet[pkg.LabelPackage(label)] { + continue + } + for configStr, hashHex := range configMap { + hashBytes, err := hex.DecodeString(hashHex) + if err != nil { + return nil, fmt.Errorf("invalid seed hash for %s: %w", label, err) + } + hashes[label+"\x00"+configStr] = hashBytes + } + } + return hashes, nil +} + func countDirtySeedTargets(targetHashes map[string]map[string]string, dirtyLabels map[string]bool) int { count := 0 for label := range targetHashes { diff --git a/pkg/dirty_set.go b/pkg/dirty_set.go index 32ca836..fee71bf 100644 --- a/pkg/dirty_set.go +++ b/pkg/dirty_set.go @@ -1,6 +1,7 @@ package pkg import ( + "encoding/hex" "path/filepath" "sort" "strings" @@ -170,6 +171,118 @@ func ComputeDirtySet( return result } +// PruneDirtySet narrows a DirtySetResult by re-propagating reverse +// dependencies only from targets whose hashes actually changed. The caller +// must supply probeHashes — a map from "label\x00configuration" to the hex +// hash computed at the destination revision — for every target in the +// directly dirty packages. Targets whose probe hash matches the seed are +// excluded from rdeps propagation, dramatically reducing the dirty set when +// a BUILD.bazel change doesn't affect most existing targets (e.g. adding a +// new target to a high-fanout package). +// +// seedHashes is the TargetHashes map from the seed file (label → config → hex hash). +// edges is the TargetEdges map from the seed file. +// original is the DirtySetResult from ComputeDirtySet. +// probeHashes maps "label\x00config" → hex hash for targets in dirty packages. +// +// PruneDirtySet preserves the original DirtyLabels and DirtyPackages +// (packages still need re-listing via wildcards) but recomputes +// DirtyStarLabels from scratch. +func PruneDirtySet( + original *DirtySetResult, + seedHashes map[string]map[string]string, + edges map[string][]string, + probeHashes map[string]string, +) *DirtySetResult { + actuallyChanged := findChangedTargets(original.DirtyLabels, seedHashes, probeHashes) + newDirtyStar := propagateFrom(original.DirtyLabels, actuallyChanged, edges) + + return &DirtySetResult{ + DirtyLabels: original.DirtyLabels, + DirtyStarLabels: newDirtyStar, + DirtyPackages: original.DirtyPackages, + } +} + +// findChangedTargets returns the subset of dirtyLabels whose probe hash +// differs from the seed. New targets (absent from seed) and targets absent +// from probe results are treated as changed. +func findChangedTargets( + dirtyLabels map[string]bool, + seedHashes map[string]map[string]string, + probeHashes map[string]string, +) map[string]bool { + changed := make(map[string]bool) + for label := range dirtyLabels { + if targetHashChanged(label, seedHashes[label], probeHashes) { + changed[label] = true + } + } + return changed +} + +// targetHashChanged reports whether a single target's probe hash differs +// from its seed hash. Returns true for new targets (nil seedConfigs) and +// targets missing from probe results. +func targetHashChanged(label string, seedConfigs map[string]string, probeHashes map[string]string) bool { + if seedConfigs == nil { + return true + } + for config, seedHex := range seedConfigs { + probeHex, ok := probeHashes[label+"\x00"+config] + if !ok || probeHex != seedHex { + return true + } + } + return false +} + +// propagateFrom builds DirtyStarLabels by including all directly dirty +// labels and BFS-propagating rdeps only from the actuallyChanged subset. +func propagateFrom(dirtyLabels, actuallyChanged map[string]bool, edges map[string][]string) map[string]bool { + rdeps := BuildRdeps(edges) + result := make(map[string]bool, len(actuallyChanged)) + + for label := range dirtyLabels { + result[label] = true + } + + queue := make([]string, 0, len(actuallyChanged)) + for label := range actuallyChanged { + queue = append(queue, label) + } + for len(queue) > 0 { + current := queue[0] + queue = queue[1:] + for _, rdep := range rdeps[current] { + if !result[rdep] { + result[rdep] = true + queue = append(queue, rdep) + } + } + } + return result +} + +// ProbeHashesFromQueryResults extracts hex-encoded hashes for all matching +// targets in a QueryResults, keyed as "label\x00configuration". This is +// used by the probe phase to compare against seed hashes. +func ProbeHashesFromQueryResults(queryResults *QueryResults) (map[string]string, error) { + hashes := make(map[string]string) + for _, label := range queryResults.MatchingTargets.Labels() { + for _, cfg := range queryResults.MatchingTargets.ConfigurationsFor(label) { + hash, err := queryResults.TargetHashCache.Hash(LabelAndConfiguration{ + Label: label, Configuration: cfg, + }) + if err != nil { + return nil, err + } + hashes[label.String()+"\x00"+cfg.String()] = hex.EncodeToString(hash) + } + } + return hashes, nil +} + func isFallbackTrigger(basename string) bool { if strings.HasSuffix(basename, ".bzl") { return true diff --git a/pkg/dirty_set_test.go b/pkg/dirty_set_test.go index 2aaee6f..8894f83 100644 --- a/pkg/dirty_set_test.go +++ b/pkg/dirty_set_test.go @@ -462,3 +462,131 @@ func TestLabelToPackage(t *testing.T) { } } } + +func TestPruneDirtySetEliminatesUnchangedRdeps(t *testing.T) { + // Setup: package //tools/binaries has two aliases. //app:binary depends + // on //tools/binaries:existing_tool. A BUILD.bazel change makes both + // aliases directly dirty, propagating to //app:binary. + edges := map[string][]string{ + "//tools/binaries:existing_tool": {}, + "//tools/binaries:new_tool": {}, + "//app:binary": {"//tools/binaries:existing_tool"}, + "//other:lib": {"//app:binary"}, + } + allLabels := CollectAllLabels(edges, nil) + + changedFiles := map[string]string{ + "tools/binaries/BUILD.bazel": "M", + } + + original := ComputeDirtySet(changedFiles, edges, allLabels, nil) + + // Verify the unpruned dirty set cascades broadly. + if !original.DirtyStarLabels["//app:binary"] { + t.Fatal("expected //app:binary in unpruned DirtyStarLabels") + } + if !original.DirtyStarLabels["//other:lib"] { + t.Fatal("expected //other:lib in unpruned DirtyStarLabels") + } + + // Seed hashes: existing_tool has hash "aaaa...", new_tool is absent (new target). + seedHashes := map[string]map[string]string{ + "//tools/binaries:existing_tool": {"": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}, + "//app:binary": {"": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}, + "//other:lib": {"": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"}, + } + + // Probe hashes: existing_tool is UNCHANGED, new_tool is new. + probeHashes := map[string]string{ + "//tools/binaries:existing_tool\x00": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "//tools/binaries:new_tool\x00": "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + } + + pruned := PruneDirtySet(original, seedHashes, edges, probeHashes) + + // Directly dirty labels should be preserved. + if !pruned.DirtyStarLabels["//tools/binaries:existing_tool"] { + t.Error("expected //tools/binaries:existing_tool in pruned DirtyStarLabels (still directly dirty)") + } + if !pruned.DirtyStarLabels["//tools/binaries:new_tool"] { + t.Error("expected //tools/binaries:new_tool in pruned DirtyStarLabels (new target, actually changed)") + } + + // The key assertion: //app:binary and //other:lib should NOT be in the + // pruned dirty set because existing_tool's hash didn't change. + // new_tool has no rdeps, so its change doesn't propagate. + if pruned.DirtyStarLabels["//app:binary"] { + t.Error("//app:binary should have been pruned — its dep //tools/binaries:existing_tool is unchanged") + } + if pruned.DirtyStarLabels["//other:lib"] { + t.Error("//other:lib should have been pruned — transitive dep unchanged") + } + + // DirtyPackages should be preserved. + if len(pruned.DirtyPackages) != 1 || pruned.DirtyPackages[0] != "//tools/binaries" { + t.Errorf("expected DirtyPackages=[//tools/binaries], got %v", pruned.DirtyPackages) + } +} + +func TestPruneDirtySetPreservesChangedRdeps(t *testing.T) { + // When a target's hash actually changes, its rdeps must remain dirty. + edges := map[string][]string{ + "//lib:changed": {"//lib:src.java"}, + "//lib:same": {}, + "//app:consumer": {"//lib:changed"}, + } + allLabels := CollectAllLabels(edges, nil) + + original := ComputeDirtySet( + map[string]string{"lib/BUILD.bazel": "M"}, edges, allLabels, nil, + ) + + seedHashes := map[string]map[string]string{ + "//lib:changed": {"": "aaaa0000aaaa0000aaaa0000aaaa0000aaaa0000aaaa0000aaaa0000aaaa0000"}, + "//lib:same": {"": "bbbb0000bbbb0000bbbb0000bbbb0000bbbb0000bbbb0000bbbb0000bbbb0000"}, + "//lib:src.java": {"": "cccc0000cccc0000cccc0000cccc0000cccc0000cccc0000cccc0000cccc0000"}, + "//app:consumer": {"": "dddd0000dddd0000dddd0000dddd0000dddd0000dddd0000dddd0000dddd0000"}, + } + + // Probe: //lib:changed has a DIFFERENT hash, //lib:same is unchanged. + probeHashes := map[string]string{ + "//lib:changed\x00": "ffff0000ffff0000ffff0000ffff0000ffff0000ffff0000ffff0000ffff0000", + "//lib:same\x00": "bbbb0000bbbb0000bbbb0000bbbb0000bbbb0000bbbb0000bbbb0000bbbb0000", + "//lib:src.java\x00": "cccc0000cccc0000cccc0000cccc0000cccc0000cccc0000cccc0000cccc0000", + } + + pruned := PruneDirtySet(original, seedHashes, edges, probeHashes) + + if !pruned.DirtyStarLabels["//app:consumer"] { + t.Error("//app:consumer must remain dirty — its dep //lib:changed has a different hash") + } +} + +func TestPruneDirtySetNoOpWhenAllChanged(t *testing.T) { + edges := map[string][]string{ + "//pkg:a": {}, + "//app:dep": {"//pkg:a"}, + } + allLabels := CollectAllLabels(edges, nil) + + original := ComputeDirtySet( + map[string]string{"pkg/BUILD.bazel": "M"}, edges, allLabels, nil, + ) + + seedHashes := map[string]map[string]string{ + "//pkg:a": {"": "aaaa0000aaaa0000aaaa0000aaaa0000aaaa0000aaaa0000aaaa0000aaaa0000"}, + "//app:dep": {"": "bbbb0000bbbb0000bbbb0000bbbb0000bbbb0000bbbb0000bbbb0000bbbb0000"}, + } + + // Probe: //pkg:a hash changed. + probeHashes := map[string]string{ + "//pkg:a\x00": "ffff0000ffff0000ffff0000ffff0000ffff0000ffff0000ffff0000ffff0000", + } + + pruned := PruneDirtySet(original, seedHashes, edges, probeHashes) + + // Everything should remain dirty — same as unpruned. + if !pruned.DirtyStarLabels["//app:dep"] { + t.Error("//app:dep must remain dirty when //pkg:a changed") + } +} From 8a2355ce37ae11a9bce6a7478bd265cb6e24946d Mon Sep 17 00:00:00 2001 From: Hongxin Liang Date: Wed, 23 Sep 2026 13:45:34 +0200 Subject: [PATCH 02/15] debug(hash-persister): log which probe targets changed vs unchanged Temporary diagnostic to understand why 191K targets remain dirty after probe pruning. Logs each changed target with its reason (new_target, hash_differs, missing_from_probe). Co-Authored-By: Claude Opus 4.6 (1M context) --- pkg/dirty_set.go | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/pkg/dirty_set.go b/pkg/dirty_set.go index fee71bf..827491d 100644 --- a/pkg/dirty_set.go +++ b/pkg/dirty_set.go @@ -2,6 +2,7 @@ package pkg import ( "encoding/hex" + "log" "path/filepath" "sort" "strings" @@ -213,9 +214,35 @@ func findChangedTargets( probeHashes map[string]string, ) map[string]bool { changed := make(map[string]bool) + unchanged := make(map[string]bool) for label := range dirtyLabels { if targetHashChanged(label, seedHashes[label], probeHashes) { changed[label] = true + } else { + unchanged[label] = true + } + } + if len(changed) > 0 || len(unchanged) > 0 { + log.Printf("Probe hash comparison: %d changed, %d unchanged out of %d dirty labels", + len(changed), len(unchanged), len(dirtyLabels)) + for label := range changed { + reason := "hash_differs" + if seedHashes[label] == nil { + reason = "new_target" + } else { + for config, seedHex := range seedHashes[label] { + probeHex, ok := probeHashes[label+"\x00"+config] + if !ok { + reason = "missing_from_probe" + break + } + if probeHex != seedHex { + reason = "hash_differs" + break + } + } + } + log.Printf(" changed: %s (%s)", label, reason) } } return changed From 4cf16848808aca7d607177dfd727803197a0f319 Mon Sep 17 00:00:00 2001 From: Hongxin Liang Date: Wed, 23 Sep 2026 14:49:29 +0200 Subject: [PATCH 03/15] perf(hash-persister): persist dependency hashes in seedable output Include hashes for edge-map-only targets (manual-tagged deps, platform() rules, generated file outputs) in the seedable target_hashes. These targets are already computed in the hash cache via recursive Hash() calls but were previously not persisted, causing the probe to treat them as "new" and over-propagate rdeps. Also removes the temporary diagnostic logging from the previous commit. Co-Authored-By: Claude Opus 4.6 (1M context) --- hash-persister/hash-persister.go | 1 + pkg/dirty_set.go | 27 ------------------------ pkg/hash_persistence.go | 35 ++++++++++++++++++++++++++++++++ pkg/hash_persistence_test.go | 17 ++++++++++++++-- 4 files changed, 51 insertions(+), 29 deletions(-) diff --git a/hash-persister/hash-persister.go b/hash-persister/hash-persister.go index c83b304..466d133 100644 --- a/hash-persister/hash-persister.go +++ b/hash-persister/hash-persister.go @@ -590,6 +590,7 @@ func mergePersistedData( } } mergedEdges := mergePersistedEntries(seedData.TargetEdges, dirtyLabels, freshEdges) + pkg.AddDependencyHashes(persistedData.TargetHashes, mergedEdges, queryResults.TargetHashCache) persistedData.FormatVersion = pkg.CurrentPersistedHashFormatVersion persistedData.SeedCompatibilityFingerprint = compatibilityFingerprint persistedData.TargetEdges = mergedEdges diff --git a/pkg/dirty_set.go b/pkg/dirty_set.go index 827491d..fee71bf 100644 --- a/pkg/dirty_set.go +++ b/pkg/dirty_set.go @@ -2,7 +2,6 @@ package pkg import ( "encoding/hex" - "log" "path/filepath" "sort" "strings" @@ -214,35 +213,9 @@ func findChangedTargets( probeHashes map[string]string, ) map[string]bool { changed := make(map[string]bool) - unchanged := make(map[string]bool) for label := range dirtyLabels { if targetHashChanged(label, seedHashes[label], probeHashes) { changed[label] = true - } else { - unchanged[label] = true - } - } - if len(changed) > 0 || len(unchanged) > 0 { - log.Printf("Probe hash comparison: %d changed, %d unchanged out of %d dirty labels", - len(changed), len(unchanged), len(dirtyLabels)) - for label := range changed { - reason := "hash_differs" - if seedHashes[label] == nil { - reason = "new_target" - } else { - for config, seedHex := range seedHashes[label] { - probeHex, ok := probeHashes[label+"\x00"+config] - if !ok { - reason = "missing_from_probe" - break - } - if probeHex != seedHex { - reason = "hash_differs" - break - } - } - } - log.Printf(" changed: %s (%s)", label, reason) } } return changed diff --git a/pkg/hash_persistence.go b/pkg/hash_persistence.go index d95fb63..ee43654 100644 --- a/pkg/hash_persistence.go +++ b/pkg/hash_persistence.go @@ -7,6 +7,7 @@ import ( "fmt" "os" "sort" + "strings" "time" "github.com/bazel-contrib/target-determinator/third_party/protobuf/bazel/build" @@ -183,6 +184,7 @@ func persistHashes(filePath string, gitCommitSha string, queryResults *QueryResu if err != nil { return fmt.Errorf("failed to extract target edges: %w", err) } + AddDependencyHashes(targetHashes, targetEdges, queryResults.TargetHashCache) compatibilityFingerprint, err := ComputeSeedCompatibilityFingerprint(context, targetsPattern, queryResults.BazelRelease) if err != nil { return err @@ -195,6 +197,39 @@ func persistHashes(filePath string, gitCommitSha string, queryResults *QueryResu return writePersistedData(filePath, &persistedData, !seedable) } +// addDependencyHashes supplements targetHashes with hashes for labels that +// appear in the edge map but are not matching targets (e.g. manual-tagged +// deps, platform() rules, generated file outputs). These hashes are already +// computed in the cache via recursive Hash() calls. Including them in the +// seed lets the probe compare them and avoid false-positive rdeps +// propagation. +func AddDependencyHashes(targetHashes map[string]map[string]string, edges map[string][]string, cache *TargetHashCache) { + allCachedHashes := cache.ExtractHashes() + + // Index cached hashes by label for efficient lookup. + cachedByLabel := make(map[string]map[string]string) + for key, hashBytes := range allCachedHashes { + idx := strings.IndexByte(key, '\x00') + if idx < 0 { + continue + } + label, config := key[:idx], key[idx+1:] + if cachedByLabel[label] == nil { + cachedByLabel[label] = make(map[string]string) + } + cachedByLabel[label][config] = hex.EncodeToString(hashBytes) + } + + for label := range edges { + if _, ok := targetHashes[label]; ok { + continue + } + if configs, ok := cachedByLabel[label]; ok { + targetHashes[label] = configs + } + } +} + // WritePersistedData writes a PersistedHashData struct directly to a JSON file. // Used by the seeded path which builds PersistedHashData by merging seed and // freshly computed data instead of extracting from QueryResults. diff --git a/pkg/hash_persistence_test.go b/pkg/hash_persistence_test.go index 260cc00..a12926d 100644 --- a/pkg/hash_persistence_test.go +++ b/pkg/hash_persistence_test.go @@ -360,7 +360,20 @@ func TestPersistenceModesPreserveHashesAndGeneratedFileEdges(t *testing.T) { if got := seedable.TargetEdges[outputLabel.String()]; len(got) != 1 || got[0] != generatorLabel.String() { t.Fatalf("seedable generated-file edge = %v, want [%s]", got, generatorLabel) } - if !reflect.DeepEqual(seedable.TargetHashes, legacy.TargetHashes) { - t.Fatalf("seedable hashes differ from legacy hashes:\nseedable: %v\nlegacy: %v", seedable.TargetHashes, legacy.TargetHashes) + // Seedable hashes are a superset of legacy hashes — they include + // dependency-only targets (e.g. generated files) that appear in the + // edge map but not in MatchingTargets. + for label, legacyConfigs := range legacy.TargetHashes { + seedableConfigs, ok := seedable.TargetHashes[label] + if !ok { + t.Fatalf("seedable hashes missing legacy label %s", label) + } + if !reflect.DeepEqual(seedableConfigs, legacyConfigs) { + t.Fatalf("seedable hashes for %s differ from legacy: seedable=%v legacy=%v", label, seedableConfigs, legacyConfigs) + } + } + // The generated file output should now have a hash in seedable mode. + if _, ok := seedable.TargetHashes[outputLabel.String()]; !ok { + t.Fatal("seedable hashes should include dependency-only target //gen:output") } } From 1beee13838d45f7453190c9ed9f22ebc36edf152 Mon Sep 17 00:00:00 2001 From: Hongxin Liang Date: Wed, 23 Sep 2026 15:15:00 +0200 Subject: [PATCH 04/15] fix(hash-persister): extract probe hashes from full cache, not just matching targets The probe query uses the same targets pattern that excludes manual-tagged targets. ProbeHashesFromQueryResults only extracted hashes for matching targets, missing dependency-only labels (platform, npm, generated files) even though their hashes were computed transitively during PrefillCache. Use ProbeHashesFromCache to extract all cached hashes, so the probe can compare every label in the seed against its current hash. Co-Authored-By: Claude Opus 4.6 (1M context) --- hash-persister/hash-persister.go | 6 +++++- pkg/dirty_set.go | 13 +++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/hash-persister/hash-persister.go b/hash-persister/hash-persister.go index 466d133..4c304ff 100644 --- a/hash-persister/hash-persister.go +++ b/hash-persister/hash-persister.go @@ -463,7 +463,11 @@ func probePackageHashes( return nil, fmt.Errorf("probe hashing failed: %w", err) } - return pkg.ProbeHashesFromQueryResults(probeResults) + // Extract hashes for matching targets plus all transitively-computed + // dependency hashes (manual-tagged, platform, generated file targets). + // ProbeHashesFromQueryResults only returns matching targets; the cache + // also has hashes for their deps computed during PrefillCache. + return pkg.ProbeHashesFromCache(probeResults) } // buildExternalSeedHashes collects seed hashes for all targets NOT in the diff --git a/pkg/dirty_set.go b/pkg/dirty_set.go index fee71bf..77b8fe4 100644 --- a/pkg/dirty_set.go +++ b/pkg/dirty_set.go @@ -283,6 +283,19 @@ func ProbeHashesFromQueryResults(queryResults *QueryResults) (map[string]string, return hashes, nil } +// ProbeHashesFromCache extracts hex-encoded hashes for ALL targets in the +// hash cache, not just matching targets. This includes dependency-only +// targets (manual-tagged, platform rules, generated files) whose hashes +// were computed transitively during PrefillCache. +func ProbeHashesFromCache(queryResults *QueryResults) (map[string]string, error) { + allCachedHashes := queryResults.TargetHashCache.ExtractHashes() + hashes := make(map[string]string, len(allCachedHashes)) + for key, hashBytes := range allCachedHashes { + hashes[key] = hex.EncodeToString(hashBytes) + } + return hashes, nil +} + func isFallbackTrigger(basename string) bool { if strings.HasSuffix(basename, ".bzl") { return true From 4ec2be1e251e822b80febc9b579e2a50baa24f01 Mon Sep 17 00:00:00 2001 From: Hongxin Liang Date: Wed, 23 Sep 2026 15:43:10 +0200 Subject: [PATCH 05/15] fix(hash-persister): cover all dirty labels in seed and probe Two gaps left the probe unable to compare most dirty-package labels, so they were conservatively marked changed and their reverse dependencies propagated anyway. Seed side: AddDependencyHashes iterated only edge keys, missing leaf labels that appear solely as dependency values (source files, npm /ref targets). Measured on a real seed, 4417 of 13081 dirty labels were missed this way, including //:service-info.yaml whose rdeps closure is 95k targets. Probe side: the probe query applied the targets pattern, which excludes manual-tagged targets. platform() rules, npm link targets and JS build internals are all manual-tagged, so the probe hashed only 115 of 13081 dirty labels. Query the dirty packages raw with ":*" instead, which covers manual-tagged rules and source files alike. With both gaps closed every dirty label has a seed hash and a probe hash to compare. On the measured case only 7 targets genuinely change, and their combined rdeps closure is 7, so dirty* should fall from ~280k to roughly the 13081 directly dirty labels. Co-Authored-By: Claude Opus 5 (1M context) --- hash-persister/hash-persister.go | 30 +++++++++++++++++++++++++----- pkg/hash_persistence.go | 13 +++++++++++-- 2 files changed, 36 insertions(+), 7 deletions(-) diff --git a/hash-persister/hash-persister.go b/hash-persister/hash-persister.go index 4c304ff..5f9364f 100644 --- a/hash-persister/hash-persister.go +++ b/hash-persister/hash-persister.go @@ -14,6 +14,7 @@ import ( "os" "os/exec" "path/filepath" + "strings" "time" "github.com/bazel-contrib/target-determinator/cli" @@ -434,11 +435,13 @@ func probePackageHashes( dirtyPackages []string, seedData *pkg.PersistedHashData, ) (map[string]string, error) { - probeUniverse := pkg.BuildScopedUniverse(dirtyPackages, nil) - probePattern, err := pkg.ScopeTargetsPattern(cfg.Targets.String(), probeUniverse) - if err != nil { - return nil, fmt.Errorf("cannot scope probe pattern: %w", err) - } + // Query the dirty packages raw, deliberately bypassing the targets + // pattern. That pattern excludes manual-tagged targets, which covers + // most labels in a dirty package (npm links, platform() rules, JS build + // internals). Those labels still appear in the seed's edge map, so the + // probe must hash them to prove they are unchanged. ":*" rather than + // ":all" so source files are included too. + probePattern := buildProbePattern(dirtyPackages) probeTargets, err := pkg.ParseTargetsList(probePattern) if err != nil { return nil, fmt.Errorf("failed to parse probe targets: %w", err) @@ -470,6 +473,23 @@ func probePackageHashes( return pkg.ProbeHashesFromCache(probeResults) } +// buildProbePattern returns a bazel query expression covering every target +// in the given packages, including manual-tagged rules and source files. +func buildProbePattern(dirtyPackages []string) string { + if len(dirtyPackages) == 0 { + return "set()" + } + terms := make([]string, 0, len(dirtyPackages)) + for _, p := range dirtyPackages { + if p == "//" { + terms = append(terms, "//:*") + } else { + terms = append(terms, p+":*") + } + } + return "(" + strings.Join(terms, " + ") + ")" +} + // buildExternalSeedHashes collects seed hashes for all targets NOT in the // given packages, so that dependency hashes resolve without a full query. func buildExternalSeedHashes( diff --git a/pkg/hash_persistence.go b/pkg/hash_persistence.go index ee43654..3febf57 100644 --- a/pkg/hash_persistence.go +++ b/pkg/hash_persistence.go @@ -220,14 +220,23 @@ func AddDependencyHashes(targetHashes map[string]map[string]string, edges map[st cachedByLabel[label][config] = hex.EncodeToString(hashBytes) } - for label := range edges { + add := func(label string) { if _, ok := targetHashes[label]; ok { - continue + return } if configs, ok := cachedByLabel[label]; ok { targetHashes[label] = configs } } + // Cover both edge keys and dependency values. Leaf labels (source files, + // npm /ref targets) never appear as keys, so iterating keys alone would + // miss them and leave the probe unable to compare their hashes. + for label, deps := range edges { + add(label) + for _, dep := range deps { + add(dep) + } + } } // WritePersistedData writes a PersistedHashData struct directly to a JSON file. From 1272f9459f920c38b9eebef77397bf4ef2fff5cf Mon Sep 17 00:00:00 2001 From: Hongxin Liang Date: Wed, 23 Sep 2026 15:55:54 +0200 Subject: [PATCH 06/15] refactor(hash-persister): tidy up incremental probe after iteration Consolidation pass over the probe work, no behaviour change: - Drop ProbeHashesFromQueryResults, dead since the probe switched to reading the cache, and replace the package-level ProbeHashesFromCache with a TargetHashCache.ExtractHexHashes method next to ExtractHashes. It only ever touched the cache and could not fail, so the QueryResults parameter and error return were both noise. - Index only the labels AddDependencyHashes actually needs instead of every cached hash, avoiding a transient map-of-maps over ~530k entries. - Fix its doc comment to name the exported identifier, size the propagateFrom result map by the set it is actually filled from, and trim doc comments that restated their signatures. - Cover the two behaviours that iteration left untested: dependency values reached only as edge values, and the probe pattern shape. Co-Authored-By: Claude Opus 5 (1M context) --- hash-persister/hash-persister.go | 9 ++-- hash-persister/hash-persister_test.go | 20 +++++++++ pkg/dirty_set.go | 61 +++++---------------------- pkg/hash_cache.go | 21 +++++++++ pkg/hash_persistence.go | 60 ++++++++++++-------------- pkg/hash_persistence_test.go | 38 +++++++++++++++++ 6 files changed, 122 insertions(+), 87 deletions(-) diff --git a/hash-persister/hash-persister.go b/hash-persister/hash-persister.go index 5f9364f..d0ee043 100644 --- a/hash-persister/hash-persister.go +++ b/hash-persister/hash-persister.go @@ -466,11 +466,10 @@ func probePackageHashes( return nil, fmt.Errorf("probe hashing failed: %w", err) } - // Extract hashes for matching targets plus all transitively-computed - // dependency hashes (manual-tagged, platform, generated file targets). - // ProbeHashesFromQueryResults only returns matching targets; the cache - // also has hashes for their deps computed during PrefillCache. - return pkg.ProbeHashesFromCache(probeResults) + // Read straight from the cache rather than from MatchingTargets: it + // additionally holds the transitively-computed hashes of dependencies + // outside the probed packages, which cost nothing extra to include. + return probeResults.TargetHashCache.ExtractHexHashes(), nil } // buildProbePattern returns a bazel query expression covering every target diff --git a/hash-persister/hash-persister_test.go b/hash-persister/hash-persister_test.go index 4619e4e..14e43ce 100644 --- a/hash-persister/hash-persister_test.go +++ b/hash-persister/hash-persister_test.go @@ -280,3 +280,23 @@ func TestMergePersistedEntriesReplacesDirtyState(t *testing.T) { t.Fatalf("merged edges = %#v, want %#v", got, wantEdges) } } + +func TestBuildProbePattern(t *testing.T) { + // ":*" rather than ":all" so source files are covered, and no targets + // pattern wrapper so manual-tagged targets are not filtered out. + for _, tc := range []struct { + name string + packages []string + want string + }{ + {"empty", nil, "set()"}, + {"root", []string{"//"}, "(//:*)"}, + {"several", []string{"//", "//ci", "//tools/binaries"}, "(//:* + //ci:* + //tools/binaries:*)"}, + } { + t.Run(tc.name, func(t *testing.T) { + if got := buildProbePattern(tc.packages); got != tc.want { + t.Errorf("buildProbePattern(%v) = %q, want %q", tc.packages, got, tc.want) + } + }) + } +} diff --git a/pkg/dirty_set.go b/pkg/dirty_set.go index 77b8fe4..f863ebc 100644 --- a/pkg/dirty_set.go +++ b/pkg/dirty_set.go @@ -1,7 +1,6 @@ package pkg import ( - "encoding/hex" "path/filepath" "sort" "strings" @@ -171,23 +170,17 @@ func ComputeDirtySet( return result } -// PruneDirtySet narrows a DirtySetResult by re-propagating reverse -// dependencies only from targets whose hashes actually changed. The caller -// must supply probeHashes — a map from "label\x00configuration" to the hex -// hash computed at the destination revision — for every target in the -// directly dirty packages. Targets whose probe hash matches the seed are -// excluded from rdeps propagation, dramatically reducing the dirty set when -// a BUILD.bazel change doesn't affect most existing targets (e.g. adding a -// new target to a high-fanout package). +// PruneDirtySet narrows a DirtySetResult by propagating reverse +// dependencies only from targets whose hash actually changed, rather than +// from every target in a dirty package. A BUILD.bazel edit marks its whole +// package dirty, so in a high-fanout package the unpruned rdeps closure can +// cover most of the repository even when only one target really changed. // -// seedHashes is the TargetHashes map from the seed file (label → config → hex hash). -// edges is the TargetEdges map from the seed file. -// original is the DirtySetResult from ComputeDirtySet. -// probeHashes maps "label\x00config" → hex hash for targets in dirty packages. -// -// PruneDirtySet preserves the original DirtyLabels and DirtyPackages -// (packages still need re-listing via wildcards) but recomputes -// DirtyStarLabels from scratch. +// probeHashes must cover every label in the dirty packages, keyed as +// "label\x00configuration"; labels it omits are conservatively treated as +// changed. DirtyLabels and DirtyPackages are preserved — those packages +// still need re-listing via wildcards — and only DirtyStarLabels is +// recomputed. func PruneDirtySet( original *DirtySetResult, seedHashes map[string]map[string]string, @@ -241,7 +234,7 @@ func targetHashChanged(label string, seedConfigs map[string]string, probeHashes // labels and BFS-propagating rdeps only from the actuallyChanged subset. func propagateFrom(dirtyLabels, actuallyChanged map[string]bool, edges map[string][]string) map[string]bool { rdeps := BuildRdeps(edges) - result := make(map[string]bool, len(actuallyChanged)) + result := make(map[string]bool, len(dirtyLabels)) for label := range dirtyLabels { result[label] = true @@ -264,38 +257,6 @@ func propagateFrom(dirtyLabels, actuallyChanged map[string]bool, edges map[strin return result } -// ProbeHashesFromQueryResults extracts hex-encoded hashes for all matching -// targets in a QueryResults, keyed as "label\x00configuration". This is -// used by the probe phase to compare against seed hashes. -func ProbeHashesFromQueryResults(queryResults *QueryResults) (map[string]string, error) { - hashes := make(map[string]string) - for _, label := range queryResults.MatchingTargets.Labels() { - for _, cfg := range queryResults.MatchingTargets.ConfigurationsFor(label) { - hash, err := queryResults.TargetHashCache.Hash(LabelAndConfiguration{ - Label: label, Configuration: cfg, - }) - if err != nil { - return nil, err - } - hashes[label.String()+"\x00"+cfg.String()] = hex.EncodeToString(hash) - } - } - return hashes, nil -} - -// ProbeHashesFromCache extracts hex-encoded hashes for ALL targets in the -// hash cache, not just matching targets. This includes dependency-only -// targets (manual-tagged, platform rules, generated files) whose hashes -// were computed transitively during PrefillCache. -func ProbeHashesFromCache(queryResults *QueryResults) (map[string]string, error) { - allCachedHashes := queryResults.TargetHashCache.ExtractHashes() - hashes := make(map[string]string, len(allCachedHashes)) - for key, hashBytes := range allCachedHashes { - hashes[key] = hex.EncodeToString(hashBytes) - } - return hashes, nil -} - func isFallbackTrigger(basename string) bool { if strings.HasSuffix(basename, ".bzl") { return true diff --git a/pkg/hash_cache.go b/pkg/hash_cache.go index 60d8e6f..d18d65c 100644 --- a/pkg/hash_cache.go +++ b/pkg/hash_cache.go @@ -4,6 +4,7 @@ import ( "bytes" "crypto/sha256" "encoding/binary" + "encoding/hex" "errors" "fmt" "io" @@ -202,6 +203,26 @@ func (thc *TargetHashCache) ExtractHashes() map[string][]byte { return result } +// ExtractHexHashes is ExtractHashes with the hashes hex-encoded, matching +// the representation used in persisted hash files. +func (thc *TargetHashCache) ExtractHexHashes() map[string]string { + raw := thc.ExtractHashes() + hashes := make(map[string]string, len(raw)) + for key, hash := range raw { + hashes[key] = hex.EncodeToString(hash) + } + return hashes +} + +// splitHashKey splits a "