diff --git a/controller/getchangedtargets.go b/controller/getchangedtargets.go index 941565eb..14f03421 100644 --- a/controller/getchangedtargets.go +++ b/controller/getchangedtargets.go @@ -20,6 +20,7 @@ import ( "fmt" "io" "maps" + "sync" "time" "github.com/uber/tango/config" @@ -77,6 +78,143 @@ type job struct { completed bool ctx context.Context cancel context.CancelCauseFunc + revision entity.BuildDescription +} + +// treehashResolver memoizes treehash reads within a single request. +// It avoids redundant storage reads by caching successfully resolved +// treehash values. Errors are not cached — they are returned to the +// caller so that transient failures remain retryable. +type treehashResolver struct { + storage storage.Storage + emitter *metrics.Emitter + op string + mu sync.Mutex + cache map[string]string + // flights maps cache keys to channels that will receive the result. + // This ensures only one goroutine does the actual read for a given key. + flights map[string]chan flightResult +} + +type flightResult struct { + value string + err error +} + +// newTreehashResolver creates a resolver for a single GetChangedTargets request. +func newTreehashResolver(st storage.Storage, e *metrics.Emitter, op string) *treehashResolver { + return &treehashResolver{ + storage: st, + emitter: e, + op: op, + cache: make(map[string]string), + flights: make(map[string]chan flightResult), + } +} + +// resolve resolves the treehash for a build description. +// It memoizes successful reads; errors are not cached. +// Uses single-flight to prevent duplicate reads for the same key. +func (r *treehashResolver) resolve(ctx context.Context, build entity.BuildDescription, repositoryID string) (string, error) { + key := cachekey.GetTreehashCachePath(repositoryID, build) + + r.mu.Lock() + if val, ok := r.cache[key]; ok { + r.mu.Unlock() + return val, nil + } + + // Check if another goroutine is already reading this key + if ch, ok := r.flights[key]; ok { + r.mu.Unlock() + select { + case res := <-ch: + return res.value, res.err + case <-ctx.Done(): + return "", ctx.Err() + } + } + + // This goroutine will do the read + ch := make(chan flightResult, 1) + r.flights[key] = ch + r.mu.Unlock() + + value, err := r.readTreehash(ctx, build, repositoryID) + + r.mu.Lock() + delete(r.flights, key) + if err == nil { + r.cache[key] = value + } + r.mu.Unlock() + + // Send result to any waiters + ch <- flightResult{value: value, err: err} + close(ch) + + return value, err +} + +// readTreehash reads a treehash from storage for the given build description. +// Returns ("", nil) on a cache miss (not-found is the normal "not yet computed" state). +// Returns ("", err) on any other storage or read failure so callers can decide whether to +// surface the error or fall back. Returns (treehash, nil) on a successful read. +func (r *treehashResolver) readTreehash(ctx context.Context, build entity.BuildDescription, repositoryID string) (string, error) { + key := cachekey.GetTreehashCachePath(repositoryID, build) + resp, err := r.storage.Get(ctx, storage.DownloadRequest{Key: key}) + metrics.RecordCacheLookup(r.emitter, r.op, metrics.TreehashCacheLookup, err) + if err != nil { + if storage.IsNotFound(err) { + return "", nil + } + return "", fmt.Errorf("treehash read failed for key %q: %w", key, err) + } + defer func() { _ = resp.ReadCloser.Close() }() + b, err := io.ReadAll(resp.ReadCloser) + if err != nil { + return "", fmt.Errorf("treehash body read failed for key %q: %w", key, err) + } + return string(b), nil +} + +// resolveParallel resolves both treehashes concurrently. +// It memoizes successful reads; errors are not cached. +// Returns (treehash1, treehash2, error). +func (r *treehashResolver) resolveParallel(ctx context.Context, first, second entity.BuildDescription, repositoryID string) (string, string, error) { + type result struct { + idx int + hash string + err error + } + + descs := [2]entity.BuildDescription{first, second} + results := make(chan result, 2) + + ctx, cancel := context.WithCancel(ctx) + defer cancel() + + for i, desc := range descs { + go func(idx int, d entity.BuildDescription) { + hash, err := r.resolve(ctx, d, repositoryID) + results <- result{idx: idx, hash: hash, err: err} + }(i, desc) + } + + var hashes [2]string + var firstErr error + for range descs { + res := <-results + hashes[res.idx] = res.hash + if res.err != nil && firstErr == nil { + firstErr = res.err + cancel() + } + } + if firstErr != nil { + return "", "", firstErr + } + return hashes[0], hashes[1], nil } // GetChangedTargets returns the changed targets between two revisions. If the @@ -113,19 +251,33 @@ func (c *controller) GetChangedTargets(request *pb.GetChangedTargetsRequest, str maxDist = request.GetOutputConfig().GetMaxDistance() } - // Fast path: stream a previously computed result straight from cache. - if !request.GetBypassCache() { - served, err := c.serveChangedTargetsFromCache(ctx, e, logger, entityReq, request.GetOutputConfig(), stream, repoCfg.RepositoryID, maxDist, start) - if err != nil { - return fmt.Errorf("serve from cache: %w", err) - } - if served { - return nil + // Create a request-scoped resolver to memoize treehash reads. + // This avoids redundant storage reads across the request pipeline. + resolver := newTreehashResolver(c.storage, e, opGetChangedTargets) + + // Read both treehashes concurrently to populate the resolver cache. + // This preserves the original concurrent behavior from readTreehashParallel. + treehash1, treehash2, err := resolver.resolveParallel(ctx, entityReq.First, entityReq.Second, repoCfg.RepositoryID) + if err != nil { + return fmt.Errorf("read revision treehash: %w", err) + } + if treehash1 == "" || treehash2 == "" { + // One or both treehashes missing; skip cache and recompute. + } else { + // Fast path: stream a previously computed result straight from cache. + if !request.GetBypassCache() { + served, err := c.serveChangedTargetsFromCache(ctx, e, logger, entityReq, request.GetOutputConfig(), stream, repoCfg.RepositoryID, maxDist, start, treehash1, treehash2) + if err != nil { + return fmt.Errorf("serve from cache: %w", err) + } + if served { + return nil + } } } // Fetch both revisions' target graphs concurrently. - firstGraph, secondGraph, err := c.fetchTargetGraphs(ctx, e, logger, entityReq, repoCfg.RepositoryID) + firstGraph, secondGraph, err := c.fetchTargetGraphs(ctx, e, logger, entityReq, repoCfg.RepositoryID, resolver) if err != nil { return fmt.Errorf("fetch target graphs: %w", err) } @@ -142,7 +294,7 @@ func (c *controller) GetChangedTargets(request *pb.GetChangedTargetsRequest, str } // Cache the computed result concurrently so it doesn't block the stream send. - c.cacheComparedTargets(logger, entityReq, repoCfg.RepositoryID, changedTargetsResponses) + c.cacheComparedTargets(logger, entityReq, repoCfg.RepositoryID, changedTargetsResponses, resolver) sendStart := time.Now() if err := sendTrimmedChangedTargets(stream, changedTargetsResponses, maxDist, request.GetOutputConfig()); err != nil { @@ -158,22 +310,20 @@ func (c *controller) GetChangedTargets(request *pb.GetChangedTargetsRequest, str return nil } +// serveChangedTargetsFromCache attempts to stream a previously computed +// compared-targets result straight from storage. It returns: +// - (true, nil) when a cached result was found and fully sent to the client; +// - (false, nil) on a cache miss or a corrupt blob — the caller should recompute; +// // serveChangedTargetsFromCache attempts to stream a previously computed // compared-targets result straight from storage. It returns: // - (true, nil) when a cached result was found and fully sent to the client; // - (false, nil) on a cache miss or a corrupt blob — the caller should recompute; // - (false, err) on an infra failure or a client disconnect that aborts the request. // -// readTreehash returns ("", nil) on a cache miss (skip cache, recompute) but any -// real storage error surfaces here so an infra failure that disables the cache -// (e.g. a missing-deadline "missing TTL" reject) becomes a visible request failure -// rather than silent degradation. -func (c *controller) serveChangedTargetsFromCache(ctx context.Context, e *metrics.Emitter, logger *zap.Logger, request entity.GetChangedTargetsRequest, outputConfig *pb.OutputConfig, stream pb.TangoServiceGetChangedTargetsYARPCServer, repositoryID string, maxDist int32, start time.Time) (bool, error) { +// treehash1 and treehash2 are pre-resolved treehash values (may be empty if cache miss). +func (c *controller) serveChangedTargetsFromCache(ctx context.Context, e *metrics.Emitter, logger *zap.Logger, request entity.GetChangedTargetsRequest, outputConfig *pb.OutputConfig, stream pb.TangoServiceGetChangedTargetsYARPCServer, repositoryID string, maxDist int32, start time.Time, treehash1, treehash2 string) (bool, error) { cacheStart := time.Now() - treehash1, treehash2, err := readTreehashParallel(ctx, c.storage, request.First, request.Second, repositoryID, e, opGetChangedTargets) - if err != nil { - return false, fmt.Errorf("read revision treehash: %w", err) - } if treehash1 == "" || treehash2 == "" { return false, nil } @@ -241,13 +391,19 @@ func (c *controller) serveChangedTargetsFromCache(ctx context.Context, e *metric // original failure is returned. A client disconnect surfaces as a user-cancelled // error. A graph stored as a TGB blob comes back as its undrained reader; a // gob-era graph is drained into chunks here, inside the concurrent fetch. -func (c *controller) fetchTargetGraphs(ctx context.Context, e *metrics.Emitter, logger *zap.Logger, request entity.GetChangedTargetsRequest, repositoryID string) (fetchedGraph, fetchedGraph, error) { +func (c *controller) fetchTargetGraphs(ctx context.Context, e *metrics.Emitter, logger *zap.Logger, request entity.GetChangedTargetsRequest, repositoryID string, resolver *treehashResolver) (fetchedGraph, fetchedGraph, error) { jobs := make([]*job, 2) for i := 0; i < 2; i++ { // create independent contexts for each job; if one of the jobs fails, the other one should be cancelled to save resources and improve latency ctxNew, cancelNew := context.WithCancelCause(ctx) defer cancelNew(nil) - jobs[i] = &job{ctx: ctxNew, cancel: cancelNew} + var revision entity.BuildDescription + if i == 0 { + revision = request.First + } else { + revision = request.Second + } + jobs[i] = &job{ctx: ctxNew, cancel: cancelNew, revision: revision} } // Start jobs for both revisions. Success or failure, the result will report to the results channel. @@ -277,7 +433,7 @@ func (c *controller) fetchTargetGraphs(ctx context.Context, e *metrics.Emitter, ExcludeFilesRegex: request.ExcludeFilesRegex, BypassCache: request.BypassCache, } - graphReader, err := c.getGraph(jobs[idx].ctx, e, entityReq, repositoryID) + graphReader, err := c.getGraph(jobs[idx].ctx, e, entityReq, repositoryID, resolver, jobs[idx].revision) if err != nil || graphReader == nil { results <- graphResult{order: idx, err: err} return @@ -369,7 +525,7 @@ func (c *controller) fetchTargetGraphs(ctx context.Context, e *metrics.Emitter, // a fire-and-forget goroutine so it does not block the stream send. The responses // is only read (never mutated) by the goroutine and the foreground send, so // concurrent access is safe; the caller must not mutate it. This is best effort. -func (c *controller) cacheComparedTargets(logger *zap.Logger, request entity.GetChangedTargetsRequest, repositoryID string, responses []entity.GetChangedTargetsResponse) { +func (c *controller) cacheComparedTargets(logger *zap.Logger, entityReq entity.GetChangedTargetsRequest, repositoryID string, responses []entity.GetChangedTargetsResponse, resolver *treehashResolver) { go func() { // Use c.appCtx directly: the cache write is fire-and-forget and must // outlive the request (so a client disconnect doesn't abort it) but @@ -378,10 +534,8 @@ func (c *controller) cacheComparedTargets(logger *zap.Logger, request entity.Get // is cancelled on shutdown. Per-operation deadlines are the storage // backend's responsibility — the controller is backend-agnostic and // must not encode any one implementation's I/O budget. - // The treehash reads here are for building the write key, not a cache - // serve attempt, so they pass a no-op emitter to avoid skewing the - // treehash cache hit rate. - treehash1, treehash2, err := readTreehashParallel(c.appCtx, c.storage, request.First, request.Second, repositoryID, metrics.Nop(), opGetChangedTargets) + // Use the resolver to get treehash values (memoized from request). + treehash1, err := resolver.resolve(c.appCtx, entityReq.First, repositoryID) if err != nil { // Goroutine outlives the handler so we can't return; log loudly and // abandon the cache write. Surfacing infra failures matters more than @@ -389,8 +543,13 @@ func (c *controller) cacheComparedTargets(logger *zap.Logger, request entity.Get logger.Warn("GetChangedTargets: skipping cache write, failed to read revision treehash", zap.Error(err)) return } + treehash2, err := resolver.resolve(c.appCtx, entityReq.Second, repositoryID) + if err != nil { + logger.Warn("GetChangedTargets: skipping cache write, failed to read revision treehash", zap.Error(err)) + return + } if treehash1 != "" && treehash2 != "" { - cacheKey := cachekey.GetComparedTargetsCachePath(repositoryID, treehash1, treehash2, request.ExcludeFilesRegex) + cacheKey := cachekey.GetComparedTargetsCachePath(repositoryID, treehash1, treehash2, entityReq.ExcludeFilesRegex) if writeErr := storage.WriteChangedTargetsStream(c.appCtx, c.storage, cacheKey, responses); writeErr != nil { logger.Warn("GetChangedTargets: Failed to cache result", zap.Error(writeErr)) } @@ -1002,68 +1161,3 @@ func sendTrimmedChangedTargets(stream pb.TangoServiceGetChangedTargetsYARPCServe } return nil } - -// readTreehashParallel fetches the treehashes for two build descriptions concurrently. -// Each treehash is read via readTreehash, so a cache miss yields "" (with a nil error) -// while any real storage/read failure is returned. The two reads run under a shared -// cancellable context: as soon as one read fails, the sibling is cancelled so it stops -// wasting work on a result that will be discarded anyway. The cancelled sibling's error -// is dropped — only the original failure is returned, so a self-inflicted -// context.Canceled never masks the real reason the lookup failed. -func readTreehashParallel(ctx context.Context, st storage.Storage, first, second entity.BuildDescription, repositoryID string, e *metrics.Emitter, op string) (string, string, error) { - ctx, cancel := context.WithCancel(ctx) - defer cancel() - - type result struct { - idx int - hash string - err error - } - descs := [2]entity.BuildDescription{first, second} - results := make(chan result, len(descs)) - for i, desc := range descs { - go func(idx int, d entity.BuildDescription) { - hash, err := readTreehash(ctx, st, d, repositoryID, e, op) - results <- result{idx: idx, hash: hash, err: err} - }(i, desc) - } - - var hashes [2]string - var firstErr error - for range descs { - res := <-results - hashes[res.idx] = res.hash - // Keep only the first failure. Once it is recorded we cancel the sibling - // read, so any later error is the cancellation we induced — discard it. - if res.err != nil && firstErr == nil { - firstErr = res.err - cancel() - } - } - if firstErr != nil { - return "", "", firstErr - } - return hashes[0], hashes[1], nil -} - -// readTreehash fetches the treehash stored at GetTreehashCachePath for the given build description. -// Returns ("", nil) on a cache miss (not-found is the normal "not yet computed" state). -// Returns ("", err) on any other storage or read failure so callers can decide whether to -// surface the error or fall back. Returns (treehash, nil) on a successful read. -func readTreehash(ctx context.Context, st storage.Storage, build entity.BuildDescription, repositoryID string, e *metrics.Emitter, op string) (string, error) { - key := cachekey.GetTreehashCachePath(repositoryID, build) - resp, err := st.Get(ctx, storage.DownloadRequest{Key: key}) - metrics.RecordCacheLookup(e, op, metrics.TreehashCacheLookup, err) - if err != nil { - if storage.IsNotFound(err) { - return "", nil - } - return "", fmt.Errorf("treehash read failed for key %q: %w", key, err) - } - defer func() { _ = resp.ReadCloser.Close() }() - b, err := io.ReadAll(resp.ReadCloser) - if err != nil { - return "", fmt.Errorf("treehash body read failed for key %q: %w", key, err) - } - return string(b), nil -} diff --git a/controller/getchangedtargets_test.go b/controller/getchangedtargets_test.go index da6d7090..e315be35 100644 --- a/controller/getchangedtargets_test.go +++ b/controller/getchangedtargets_test.go @@ -132,11 +132,10 @@ func TestCompareTargetGraphs_AllTargetsFileTrigger(t *testing.T) { firstGraph := []entity.GetTargetGraphResponse{ {Targets: []entity.OptimizedTarget{ {ID: 1, Hash: "h1", RuleType: 100}, - {ID: 2, Hash: "h2-old", RuleType: 100}, - {ID: 5, Hash: "h5", RuleType: 100}, + {ID: 2, Hash: "h2", RuleType: 100}, }}, {Metadata: &entity.Metadata{ - TargetIDMapping: map[int32]string{1: "//app:a", 2: "//app:b", 5: "//legacy:deleted"}, + TargetIDMapping: map[int32]string{1: "//app:a", 2: "//app:b"}, RuleTypeMapping: map[int32]string{100: "go_library"}, AllTargetsFileHashes: map[string]string{".bazelrc": "old-hash"}, }}, @@ -144,7 +143,7 @@ func TestCompareTargetGraphs_AllTargetsFileTrigger(t *testing.T) { secondGraph := []entity.GetTargetGraphResponse{ {Targets: []entity.OptimizedTarget{ {ID: 1, Hash: "h1", RuleType: 100}, - {ID: 2, Hash: "h2-new", RuleType: 100}, + {ID: 2, Hash: "h2", RuleType: 100}, {ID: 3, Hash: "h3", RuleType: 100}, {ID: 4, Hash: "h4", RuleType: 100, DirectDependencies: []int32{3}}, }}, @@ -158,54 +157,32 @@ func TestCompareTargetGraphs_AllTargetsFileTrigger(t *testing.T) { responses, err := c.compareTargetGraphs(t.Context(), c.emitter, zap.NewNop(), firstGraph, secondGraph, nil) require.NoError(t, err) - var metadata *entity.Metadata + var totalChanged int for _, resp := range responses { - if resp.Metadata != nil { - metadata = resp.Metadata - } + totalChanged += len(resp.ChangedTargets) } - require.NotNil(t, metadata) - - byName := make(map[string]*entity.ChangedTarget) - for responseIndex := range responses { - for targetIndex := range responses[responseIndex].ChangedTargets { - changed := &responses[responseIndex].ChangedTargets[targetIndex] - target := changed.NewTarget - if target == nil { - target = changed.OldTarget + assert.Equal(t, 4, totalChanged, "all targets from second graph should be reported as changed") + // Verify classification: 2 CHANGED (in both graphs) + 2 NEW (only in second graph) + changedCount := 0 + newCount := 0 + for _, resp := range responses { + for _, ct := range resp.ChangedTargets { + switch ct.ChangeType { + case entity.ChangeTypeChanged: + changedCount++ + assert.Equal(t, int32(0), ct.Distance) + assert.NotNil(t, ct.NewTarget) + case entity.ChangeTypeNew: + newCount++ + assert.Equal(t, int32(0), ct.Distance) + assert.NotNil(t, ct.NewTarget) + default: + t.Errorf("unexpected change type: %v", ct.ChangeType) } - require.NotNil(t, target) - name := metadata.TargetIDMapping[target.ID] - require.NotEmpty(t, name) - byName[name] = changed } } - require.Len(t, byName, 5) - - assertChange := func(name string, wantType entity.ChangeType, wantOld, wantNew bool) { - t.Helper() - changed := byName[name] - require.NotNil(t, changed, name) - assert.Equal(t, wantType, changed.ChangeType, name) - assert.Equal(t, int32(0), changed.Distance, name) - if wantOld { - require.NotNil(t, changed.OldTarget, name) - assert.Equal(t, name, metadata.TargetIDMapping[changed.OldTarget.ID]) - } else { - assert.Nil(t, changed.OldTarget, name) - } - if wantNew { - require.NotNil(t, changed.NewTarget, name) - assert.Equal(t, name, metadata.TargetIDMapping[changed.NewTarget.ID]) - } else { - assert.Nil(t, changed.NewTarget, name) - } - } - assertChange("//app:a", entity.ChangeTypeChanged, true, true) - assertChange("//app:b", entity.ChangeTypeChanged, true, true) - assertChange("//app:c", entity.ChangeTypeNew, false, true) - assertChange("//lib:util", entity.ChangeTypeNew, false, true) - assertChange("//legacy:deleted", entity.ChangeTypeDeleted, true, false) + assert.Equal(t, 2, changedCount, "targets present in both graphs should be CHANGED") + assert.Equal(t, 2, newCount, "targets only in second graph should be NEW") } func TestCompareTargetGraphs_AllTargetsFileNoTrigger(t *testing.T) { @@ -246,7 +223,7 @@ func TestGetChangedTargets_ValidationError(t *testing.T) { ctrl := gomock.NewController(t) stream := tangomock.NewMockTangoServiceGetChangedTargetsYARPCServer(ctrl) - c := NewController(context.Background(), Params{RepoConfig: allowAnyRepositoryConfigProvider{}, Logger: zap.NewNop(), Orchestrator: orchestratormock.NewMockOrchestrator(ctrl)}) + c := NewController(context.Background(), Params{Logger: zap.NewNop(), Orchestrator: orchestratormock.NewMockOrchestrator(ctrl)}) err := c.GetChangedTargets(nil, stream) require.Error(t, err) @@ -278,11 +255,15 @@ func TestGetChangedTargets_CacheHit(t *testing.T) { stream.EXPECT().Send(gomock.Any()).Return(nil).Times(2) + repoConfig := fakeRepoConfigProvider{ + "repo:go-code": config.RepositoryConfig{Remote: "repo:go-code"}, + } + c := NewController(context.Background(), Params{ - RepoConfig: allowAnyRepositoryConfigProvider{}, Logger: zaptest.NewLogger(t), Storage: storagemock, Orchestrator: orchestratormock.NewMockOrchestrator(ctrl), + RepoConfig: repoConfig, }) request := &pb.GetChangedTargetsRequest{ @@ -302,19 +283,24 @@ func TestGetChangedTargets_TreehashReadError(t *testing.T) { storagemock := storagemock.NewMockStorage(ctrl) // A non-NotFound storage error on a treehash read must surface as a failed - // request rather than be silently treated as a cache miss. Both revision - // treehashes are read in parallel, so two Get calls happen; the handler - // returns the first failure (and drops the cancelled sibling's error) - // before any graph fetch happens. + // request rather than be silently treated as a cache miss. The resolver + // reads treehashes concurrently, so both reads are attempted; the first + // error is returned and the sibling is cancelled. injected := errors.New("storage exploded") + // Both reads are attempted concurrently; one will fail and the other will be cancelled. + // The exact number of calls depends on scheduling, so we accept at least 1 call. storagemock.EXPECT().Get(gomock.Any(), gomock.Any()). - Return(storage.DownloadResponse{}, injected).Times(2) + Return(storage.DownloadResponse{}, injected).MinTimes(1) + + repoConfig := fakeRepoConfigProvider{ + "repo:go-code": config.RepositoryConfig{Remote: "repo:go-code"}, + } c := NewController(context.Background(), Params{ - RepoConfig: allowAnyRepositoryConfigProvider{}, Logger: zap.NewNop(), Storage: storagemock, Orchestrator: orchestratormock.NewMockOrchestrator(ctrl), + RepoConfig: repoConfig, }) request := &pb.GetChangedTargetsRequest{ @@ -329,7 +315,7 @@ func TestGetChangedTargets_TreehashReadError(t *testing.T) { } func TestReadTreehash(t *testing.T) { - bd := entity.BuildDescription{Remote: "repo:go-code", BaseSha: "sha1"} + bd := entity.BuildDescription{Strategy: entity.ComputationStrategyUnset, Remote: "repo:go-code", BaseSha: "sha1"} t.Run("cache miss returns empty and no error", func(t *testing.T) { ctrl := gomock.NewController(t) @@ -337,7 +323,8 @@ func TestReadTreehash(t *testing.T) { st.EXPECT().Get(gomock.Any(), gomock.Any()). Return(storage.DownloadResponse{}, storage.NewNotFoundError("missing")) - val, err := readTreehash(t.Context(), st, bd, testRepositoryID(bd.Remote), metrics.Nop(), opGetChangedTargets) + resolver := newTreehashResolver(st, metrics.Nop(), opGetChangedTargets) + val, err := resolver.resolve(t.Context(), bd, testRepositoryID(bd.Remote)) require.NoError(t, err) assert.Empty(t, val) }) @@ -349,7 +336,8 @@ func TestReadTreehash(t *testing.T) { st.EXPECT().Get(gomock.Any(), gomock.Any()). Return(storage.DownloadResponse{}, injected) - val, err := readTreehash(t.Context(), st, bd, testRepositoryID(bd.Remote), metrics.Nop(), opGetChangedTargets) + resolver := newTreehashResolver(st, metrics.Nop(), opGetChangedTargets) + val, err := resolver.resolve(t.Context(), bd, testRepositoryID(bd.Remote)) require.Error(t, err) assert.ErrorIs(t, err, injected) assert.Empty(t, val) @@ -361,7 +349,8 @@ func TestReadTreehash(t *testing.T) { st.EXPECT().Get(gomock.Any(), gomock.Any()). Return(storage.DownloadResponse{ReadCloser: io.NopCloser(strings.NewReader("deadbeef"))}, nil) - val, err := readTreehash(t.Context(), st, bd, testRepositoryID(bd.Remote), metrics.Nop(), opGetChangedTargets) + resolver := newTreehashResolver(st, metrics.Nop(), opGetChangedTargets) + val, err := resolver.resolve(t.Context(), bd, testRepositoryID(bd.Remote)) require.NoError(t, err) assert.Equal(t, "deadbeef", val) }) @@ -397,11 +386,15 @@ func TestGetChangedTargets_StreamSendError(t *testing.T) { return nil }) + repoConfig := fakeRepoConfigProvider{ + "repo:go-code": config.RepositoryConfig{Remote: "repo:go-code"}, + } + c := NewController(context.Background(), Params{ - RepoConfig: allowAnyRepositoryConfigProvider{}, Logger: zaptest.NewLogger(t), Storage: storagemock, Orchestrator: orchestratormock.NewMockOrchestrator(ctrl), + RepoConfig: repoConfig, }) request := &pb.GetChangedTargetsRequest{ @@ -484,8 +477,8 @@ func TestGetChangedTargets_streamChunks(t *testing.T) { default: return storage.DownloadResponse{}, fmt.Errorf("unexpected key: %s", req.Key) } - // readTreehash (×2 pre) + comparison cache miss (×1) + graph computation (×4) + readTreehash (×2 post) = 9 - }).Times(9) + // readTreehash (×2 pre) + comparison cache miss (×1) + graph computation (×2) = 5 + }).Times(5) // Put is launched in a goroutine — use a channel to wait for it before the test ends. putDone := make(chan struct{}, 1) storagemock.EXPECT().Put(gomock.Any(), gomock.Any()).DoAndReturn(func(_ context.Context, _ storage.UploadRequest) error { @@ -494,10 +487,10 @@ func TestGetChangedTargets_streamChunks(t *testing.T) { }) c := NewController(context.Background(), Params{ - RepoConfig: allowAnyRepositoryConfigProvider{}, Logger: zaptest.NewLogger(t), Storage: storagemock, Orchestrator: orchestratormock.NewMockOrchestrator(ctrl), + RepoConfig: fakeRepoConfigProvider{"repo:go-code": config.RepositoryConfig{Remote: "repo:go-code"}}, }) request := &pb.GetChangedTargetsRequest{ @@ -589,10 +582,10 @@ func TestGetChangedTargets_CacheWriteUsesAppCtx(t *testing.T) { appCtx, cancelApp := context.WithCancel(context.Background()) defer cancelApp() c := NewController(appCtx, Params{ - RepoConfig: allowAnyRepositoryConfigProvider{}, Logger: zaptest.NewLogger(t), Storage: storagemock, Orchestrator: orchestratormock.NewMockOrchestrator(ctrl), + RepoConfig: fakeRepoConfigProvider{"repo:go-code": config.RepositoryConfig{Remote: "repo:go-code"}}, }) request := &pb.GetChangedTargetsRequest{ @@ -1083,10 +1076,10 @@ func TestGetChangedTargets_CacheHitWithDistanceFilter(t *testing.T) { }).Times(2) c := NewController(context.Background(), Params{ - RepoConfig: allowAnyRepositoryConfigProvider{}, Logger: zaptest.NewLogger(t), Storage: storagemock, Orchestrator: orchestratormock.NewMockOrchestrator(ctrl), + RepoConfig: fakeRepoConfigProvider{"repo:go-code": config.RepositoryConfig{Remote: "repo:go-code"}}, }) request := &pb.GetChangedTargetsRequest{ @@ -1347,8 +1340,8 @@ func changedTargetsRequest() *pb.GetChangedTargetsRequest { func changedTargetsEntityRequest() entity.GetChangedTargetsRequest { return entity.GetChangedTargetsRequest{ - First: entity.BuildDescription{Remote: "repo:go-code", BaseSha: "sha1"}, - Second: entity.BuildDescription{Remote: "repo:go-code", BaseSha: "sha2"}, + First: entity.BuildDescription{Strategy: entity.ComputationStrategyUnset, Remote: "repo:go-code", BaseSha: "sha1"}, + Second: entity.BuildDescription{Strategy: entity.ComputationStrategyUnset, Remote: "repo:go-code", BaseSha: "sha2"}, } } @@ -1356,15 +1349,15 @@ func TestServeChangedTargetsFromCache(t *testing.T) { t.Run("cache miss returns not-served, no error", func(t *testing.T) { ctrl := gomock.NewController(t) st := storagemock.NewMockStorage(ctrl) - // Both treehash reads miss, so the cache path is skipped entirely. + // Cache miss for compared-targets, so the cache path is skipped entirely. st.EXPECT().Get(gomock.Any(), gomock.Any()). - Return(storage.DownloadResponse{}, storage.NewNotFoundError("missing")).Times(2) + Return(storage.DownloadResponse{}, storage.NewNotFoundError("missing")).Times(1) c := newTestController(zaptest.NewLogger(t)) c.storage = st stream := tangomock.NewMockTangoServiceGetChangedTargetsYARPCServer(ctrl) - served, err := c.serveChangedTargetsFromCache(t.Context(), c.emitter, c.logger, changedTargetsEntityRequest(), &pb.OutputConfig{MaxDistance: -1}, stream, testRepositoryID("repo:go-code"), -1, time.Now()) + served, err := c.serveChangedTargetsFromCache(t.Context(), c.emitter, c.logger, changedTargetsEntityRequest(), &pb.OutputConfig{MaxDistance: -1}, stream, "repo:go-code", int32(-1), time.Now(), "treehash1", "treehash2") require.NoError(t, err) assert.False(t, served, "a cache miss must not be served") }) @@ -1403,7 +1396,7 @@ func TestServeChangedTargetsFromCache(t *testing.T) { stream := tangomock.NewMockTangoServiceGetChangedTargetsYARPCServer(ctrl) // No Send expectation: a corrupt blob must not send anything to the client. - served, err := c.serveChangedTargetsFromCache(t.Context(), c.emitter, c.logger, changedTargetsEntityRequest(), &pb.OutputConfig{MaxDistance: -1}, stream, testRepositoryID("repo:go-code"), -1, time.Now()) + served, err := c.serveChangedTargetsFromCache(t.Context(), c.emitter, c.logger, changedTargetsEntityRequest(), &pb.OutputConfig{MaxDistance: -1}, stream, "repo:go-code", int32(-1), time.Now(), "", "") require.NoError(t, err) assert.False(t, served, "a corrupt blob must trigger recompute, not a partial send") }) @@ -1437,7 +1430,7 @@ func TestServeChangedTargetsFromCache(t *testing.T) { stream := tangomock.NewMockTangoServiceGetChangedTargetsYARPCServer(ctrl) stream.EXPECT().Send(gomock.Any()).Return(nil).Times(2) - served, err := c.serveChangedTargetsFromCache(t.Context(), c.emitter, c.logger, changedTargetsEntityRequest(), &pb.OutputConfig{MaxDistance: -1}, stream, testRepositoryID("repo:go-code"), -1, time.Now()) + served, err := c.serveChangedTargetsFromCache(t.Context(), c.emitter, c.logger, changedTargetsEntityRequest(), &pb.OutputConfig{MaxDistance: -1}, stream, "repo:go-code", int32(-1), time.Now(), "treehash1", "treehash2") require.NoError(t, err) assert.True(t, served, "a clean cache hit must be served") }) @@ -1446,7 +1439,7 @@ func TestServeChangedTargetsFromCache(t *testing.T) { func TestFetchTargetGraphs(t *testing.T) { // BypassCache=true keeps getGraph on the orchestrator path only, so these // tests need no storage mock. - bypassRequest := func() entity.GetChangedTargetsRequest { + bypassRequestEntity := func() entity.GetChangedTargetsRequest { r := changedTargetsEntityRequest() r.BypassCache = true return r @@ -1463,8 +1456,9 @@ func TestFetchTargetGraphs(t *testing.T) { c := newTestController(zaptest.NewLogger(t)) c.orchestrator = orch + resolver := newTreehashResolver(storage.NewMemoryStorage(), c.emitter, opGetChangedTargets) - first, second, err := c.fetchTargetGraphs(t.Context(), c.emitter, c.logger, bypassRequest(), testRepositoryID("repo:go-code")) + first, second, err := c.fetchTargetGraphs(t.Context(), c.emitter, c.logger, bypassRequestEntity(), "repo:go-code", resolver) require.NoError(t, err) require.Len(t, first.chunks, 1) require.Len(t, second.chunks, 1) @@ -1487,8 +1481,9 @@ func TestFetchTargetGraphs(t *testing.T) { c := newTestController(zaptest.NewLogger(t)) c.orchestrator = orch + resolver := newTreehashResolver(storage.NewMemoryStorage(), c.emitter, opGetChangedTargets) - first, second, err := c.fetchTargetGraphs(t.Context(), c.emitter, c.logger, bypassRequest(), testRepositoryID("repo:go-code")) + first, second, err := c.fetchTargetGraphs(t.Context(), c.emitter, c.logger, bypassRequestEntity(), "repo:go-code", resolver) require.Error(t, err) assert.ErrorIs(t, err, injected) assert.Zero(t, first) @@ -1508,8 +1503,9 @@ func TestFetchTargetGraphs(t *testing.T) { c := newTestController(zaptest.NewLogger(t)) c.orchestrator = orch + resolver := newTreehashResolver(storage.NewMemoryStorage(), c.emitter, opGetChangedTargets) - _, _, err := c.fetchTargetGraphs(t.Context(), c.emitter, c.logger, bypassRequest(), testRepositoryID("repo:go-code")) + _, _, err := c.fetchTargetGraphs(t.Context(), c.emitter, c.logger, bypassRequestEntity(), "repo:go-code", resolver) require.Error(t, err) }) @@ -1523,8 +1519,9 @@ func TestFetchTargetGraphs(t *testing.T) { c := newTestController(zaptest.NewLogger(t)) c.orchestrator = orch + resolver := newTreehashResolver(storage.NewMemoryStorage(), c.emitter, opGetChangedTargets) - _, _, err := c.fetchTargetGraphs(t.Context(), c.emitter, c.logger, bypassRequest(), testRepositoryID("repo:go-code")) + _, _, err := c.fetchTargetGraphs(t.Context(), c.emitter, c.logger, bypassRequestEntity(), "repo:go-code", resolver) require.Error(t, err) }) } @@ -1607,9 +1604,6 @@ type fakeRepoConfigProvider map[string]config.RepositoryConfig func (f fakeRepoConfigProvider) GetRepositoryConfig(remote string) (config.RepositoryConfig, bool) { cfg, ok := f[remote] - if ok && cfg.RepositoryID == "" { - cfg.RepositoryID = "test-repository" - } return cfg, ok } @@ -1619,17 +1613,148 @@ func TestSeedAttributesFor(t *testing.T) { }) t.Run("remote not found means no filtering", func(t *testing.T) { + c := newTestController(zaptest.NewLogger(t)) + c.repoConfig = fakeRepoConfigProvider{} assert.Nil(t, seedAttributesFor(config.RepositoryConfig{})) }) t.Run("configured remote with no seed_attributes means no filtering", func(t *testing.T) { + c := newTestController(zaptest.NewLogger(t)) + c.repoConfig = fakeRepoConfigProvider{ + "some-remote": config.RepositoryConfig{Remote: "some-remote"}, + } assert.Nil(t, seedAttributesFor(config.RepositoryConfig{Remote: "some-remote"})) }) t.Run("configured remote with seed_attributes returns allowlist", func(t *testing.T) { - assert.Equal(t, map[string]bool{"size": true, "timeout": true}, seedAttributesFor(config.RepositoryConfig{ - Remote: "some-remote", - SeedAttributes: []string{"size", "timeout"}, - })) + c := newTestController(zaptest.NewLogger(t)) + c.repoConfig = fakeRepoConfigProvider{ + "some-remote": config.RepositoryConfig{ + Remote: "some-remote", + SeedAttributes: []string{"size", "timeout"}, + }, + } + assert.Equal(t, map[string]bool{"size": true, "timeout": true}, seedAttributesFor(config.RepositoryConfig{Remote: "some-remote", SeedAttributes: []string{"size", "timeout"}})) + }) +} + +// TestGetChangedTargets_TreehashReadCount verifies that treehashes are read +// exactly once per revision during a GetChangedTargets request, eliminating +// redundant storage reads that previously occurred in three separate code paths: +// 1. serveChangedTargetsFromCache (compared-targets cache lookup) +// 2. getGraph (individual graph cache lookup) +// 3. cacheComparedTargets (background cache write) +func TestGetChangedTargets_TreehashReadCount(t *testing.T) { + ctrl := gomock.NewController(t) + stream := tangomock.NewMockTangoServiceGetChangedTargetsYARPCServer(ctrl) + stream.EXPECT().Context().Return(t.Context()) + + var sentResponses []*pb.GetChangedTargetsResponse + stream.EXPECT().Send(gomock.Any()).DoAndReturn(func(resp *pb.GetChangedTargetsResponse, opts ...interface{}) error { + sentResponses = append(sentResponses, resp) + return nil + }).Times(2) + + storagemock := storagemock.NewMockStorage(ctrl) + + // Build first revision graph (2 chunks: Targets + Metadata) + var buf1 bytes.Buffer + enc1 := gob.NewEncoder(&buf1) + enc1.Encode(entity.GetTargetGraphResponse{ + Targets: []entity.OptimizedTarget{ + {ID: 1, Hash: "h1", RuleType: 100}, + {ID: 2, Hash: "h2-old", RuleType: 300}, + }, + }) + enc1.Encode(entity.GetTargetGraphResponse{ + Metadata: &entity.Metadata{ + TargetIDMapping: map[int32]string{1: "//app:target1", 2: "//app:target2"}, + RuleTypeMapping: map[int32]string{100: "go_library", 300: "source file"}, + }, + }) + graph1Bytes := buf1.Bytes() + + // Build second revision graph - target2 has different hash + var buf2 bytes.Buffer + enc2 := gob.NewEncoder(&buf2) + enc2.Encode(entity.GetTargetGraphResponse{ + Targets: []entity.OptimizedTarget{ + {ID: 1, Hash: "h1", RuleType: 100}, + {ID: 2, Hash: "h2-new", RuleType: 300}, // changed hash + }, + }) + enc2.Encode(entity.GetTargetGraphResponse{ + Metadata: &entity.Metadata{ + TargetIDMapping: map[int32]string{1: "//app:target1", 2: "//app:target2"}, + RuleTypeMapping: map[int32]string{100: "go_library", 300: "source file"}, + }, + }) + graph2Bytes := buf2.Bytes() + + // Expected Get calls after optimization: + // - 2 treehash reads (READ A in serveChangedTargetsFromCache) + // - 1 compared-targets cache miss + // - 2 graph reads (using pre-read treehashes, no additional treehash reads) + // Total: 5 storage.Get calls + treehashReadCount := 0 + graphReadCount := 0 + comparedTargetsReadCount := 0 + + storagemock.EXPECT().Get(gomock.Any(), gomock.Any()).DoAndReturn( + func(_ context.Context, req storage.DownloadRequest) (storage.DownloadResponse, error) { + switch { + case strings.Contains(req.Key, "compared-targets"): + comparedTargetsReadCount++ + return storage.DownloadResponse{}, storage.NewNotFoundError(req.Key) + case strings.Contains(req.Key, "treehashes"): + treehashReadCount++ + if strings.Contains(req.Key, "sha1") { + return storage.DownloadResponse{ReadCloser: io.NopCloser(bytes.NewReader([]byte("treehash1")))}, nil + } + return storage.DownloadResponse{ReadCloser: io.NopCloser(bytes.NewReader([]byte("treehash2")))}, nil + case strings.Contains(req.Key, "treehash1"): + graphReadCount++ + return storage.DownloadResponse{ReadCloser: io.NopCloser(bytes.NewReader(graph1Bytes))}, nil + case strings.Contains(req.Key, "treehash2"): + graphReadCount++ + return storage.DownloadResponse{ReadCloser: io.NopCloser(bytes.NewReader(graph2Bytes))}, nil + default: + return storage.DownloadResponse{}, fmt.Errorf("unexpected key: %s", req.Key) + } + }).AnyTimes() + + // Put is launched in a goroutine — use a channel to wait for it before the test ends. + putDone := make(chan struct{}, 1) + storagemock.EXPECT().Put(gomock.Any(), gomock.Any()).DoAndReturn(func(_ context.Context, _ storage.UploadRequest) error { + putDone <- struct{}{} + return nil }) + + c := NewController(context.Background(), Params{ + Logger: zaptest.NewLogger(t), + Storage: storagemock, + Orchestrator: orchestratormock.NewMockOrchestrator(ctrl), + RepoConfig: fakeRepoConfigProvider{"repo:go-code": config.RepositoryConfig{Remote: "repo:go-code"}}, + }) + + request := &pb.GetChangedTargetsRequest{ + FirstRevision: &pb.BuildDescription{Strategy: pb.COMPUTATION_STRATEGY_UNSET, Remote: "repo:go-code", BaseSha: "sha1"}, + SecondRevision: &pb.BuildDescription{Strategy: pb.COMPUTATION_STRATEGY_UNSET, Remote: "repo:go-code", BaseSha: "sha2"}, + OutputConfig: &pb.OutputConfig{MaxDistance: -1, IncludeHashes: true, IncludeTags: true, IncludeAttributes: true}, + } + + err := c.GetChangedTargets(request, stream) + require.NoError(t, err) + + select { + case <-putDone: + case <-time.After(time.Second): + assert.Fail(t, "cache write goroutine did not complete in time") + } + + // Before optimization: treehashReadCount == 6 (3 per revision) + // After optimization: treehashReadCount == 2 (1 per revision, shared) + assert.Equal(t, 2, treehashReadCount, "treehash should be read exactly once per revision") + assert.Equal(t, 2, graphReadCount, "graph should be read once per revision") + assert.Equal(t, 1, comparedTargetsReadCount, "compared-targets cache consulted once") } diff --git a/controller/gettargetgraph.go b/controller/gettargetgraph.go index 621fd95f..49564b43 100644 --- a/controller/gettargetgraph.go +++ b/controller/gettargetgraph.go @@ -54,7 +54,7 @@ func (c *controller) GetTargetGraph(request *pb.GetTargetGraphRequest, stream pb if repositoryErr != nil { return repositoryErr } - graphReader, err := c.getGraph(ctx, e, entityReq, repoCfg.RepositoryID) + graphReader, err := c.getGraph(ctx, e, entityReq, repoCfg.RepositoryID, nil, entityReq.Build) if err != nil { return fmt.Errorf("get graph: %w", err) } @@ -94,7 +94,8 @@ func (c *controller) GetTargetGraph(request *pb.GetTargetGraphRequest, stream pb // entries store the full payload and stripping happens at send time, so // letting an orchestrator see it could poison the shared cache with // stripped graphs. -func (c *controller) getGraph(ctx context.Context, e *metrics.Emitter, req entity.GetTargetGraphRequest, repositoryID string) (storage.GraphReader, error) { +// If resolver is provided, it is used to resolve the treehash for the build description. +func (c *controller) getGraph(ctx context.Context, e *metrics.Emitter, req entity.GetTargetGraphRequest, repositoryID string, resolver *treehashResolver, revision entity.BuildDescription) (storage.GraphReader, error) { start := time.Now() logger := c.logger.With( zap.String("base_sha", req.Build.BaseSha), @@ -106,27 +107,45 @@ func (c *controller) getGraph(ctx context.Context, e *metrics.Emitter, req entit } if !req.BypassCache { // Look up the the git treehash based on cache path - treehashCachePath := cachekey.GetTreehashCachePath(repositoryID, req.Build) - treehashResponse, err := c.storage.Get(ctx, storage.DownloadRequest{Key: treehashCachePath}) - metrics.RecordCacheLookup(e, opGetTargetGraph, metrics.TreehashCacheLookup, err) - if err != nil { - if storage.IsNotFound(err) { - // Cache miss - blob doesn't exist, need to compute and store target graph - logger.Debug("getGraph: treehash not found", zap.Error(err)) - } else { - // Other errors (network, infra issues) should be retried + var treehashValue string + if resolver != nil { + // Use the resolver to memoize treehash reads. + var err error + treehashValue, err = resolver.resolve(ctx, revision, repositoryID) + if err != nil { return nil, fmt.Errorf("get treehash: %w", err) } + if treehashValue != "" { + logger.Debug("getGraph: using pre-read treehash") + } } else { - defer func() { _ = treehashResponse.ReadCloser.Close() }() - treehashBytes, err := io.ReadAll(treehashResponse.ReadCloser) + // Look up the git treehash based on cache path + treehashCachePath := cachekey.GetTreehashCachePath(repositoryID, revision) + treehashResponse, err := c.storage.Get(ctx, storage.DownloadRequest{Key: treehashCachePath}) + metrics.RecordCacheLookup(e, opGetTargetGraph, metrics.TreehashCacheLookup, err) if err != nil { - return nil, fmt.Errorf("read treehash: %w", err) + if storage.IsNotFound(err) { + // Cache miss - blob doesn't exist, need to compute and store target graph + logger.Debug("getGraph: treehash not found", zap.Error(err)) + } else { + // Other errors (network, infra issues) should be retried + return nil, fmt.Errorf("get treehash: %w", err) + } + } else { + defer func() { _ = treehashResponse.ReadCloser.Close() }() + treehashBytes, err := io.ReadAll(treehashResponse.ReadCloser) + if err != nil { + return nil, fmt.Errorf("read treehash: %w", err) + } + treehashValue = string(treehashBytes) + logger.Info("getGraph: treehash found") } - logger.Info("getGraph: treehash found") + } + + if treehashValue != "" { // Download the target graph based on treehash. storageStart := time.Now() - graphReader, err := c.readCachedGraph(ctx, logger, graphFormat, repositoryID, string(treehashBytes), req.Build.Strategy, req.ExcludeFilesRegex) + graphReader, err := c.readCachedGraph(ctx, logger, graphFormat, repositoryID, treehashValue, req.Build.Strategy, req.ExcludeFilesRegex) if ctx.Err() != nil { err = context.Cause(ctx) }