diff --git a/submitqueue/extension/conflict/tango/BUILD.bazel b/submitqueue/extension/conflict/tango/BUILD.bazel new file mode 100644 index 00000000..c768a8ed --- /dev/null +++ b/submitqueue/extension/conflict/tango/BUILD.bazel @@ -0,0 +1,24 @@ +load("@rules_go//go:def.bzl", "go_library", "go_test") + +go_library( + name = "go_default_library", + srcs = ["tango.go"], + importpath = "github.com/uber/submitqueue/submitqueue/extension/conflict/tango", + visibility = ["//visibility:public"], + deps = [ + "//submitqueue/entity:go_default_library", + "//submitqueue/extension/conflict:go_default_library", + ], +) + +go_test( + name = "go_default_test", + srcs = ["tango_test.go"], + embed = [":go_default_library"], + deps = [ + "//submitqueue/entity:go_default_library", + "//submitqueue/extension/conflict:go_default_library", + "@com_github_stretchr_testify//assert:go_default_library", + "@com_github_stretchr_testify//require:go_default_library", + ], +) diff --git a/submitqueue/extension/conflict/tango/tango.go b/submitqueue/extension/conflict/tango/tango.go new file mode 100644 index 00000000..05c67767 --- /dev/null +++ b/submitqueue/extension/conflict/tango/tango.go @@ -0,0 +1,112 @@ +// Copyright (c) 2026 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package tango provides a conflict.Analyzer that reports a conflict between +// two batches when their changed build targets overlap. The targets a batch +// affects are resolved through an injected TargetResolver, whose production +// implementation calls the Tango service. +package tango + +import ( + "context" + "fmt" + + "github.com/uber/submitqueue/submitqueue/entity" + "github.com/uber/submitqueue/submitqueue/extension/conflict" +) + +// TargetResolver resolves the set of build targets a batch affects. The +// production implementation translates the batch's changes into a Tango +// GetChangedTargets call; tests supply a fake. +type TargetResolver interface { + ChangedTargets(ctx context.Context, batch entity.Batch) ([]string, error) +} + +// New returns a conflict.Analyzer that flags an in-flight batch as conflicting +// when its changed build targets overlap with the candidate batch's, bound to +// the queue named in cfg. +func New(cfg conflict.Config, targets TargetResolver) conflict.Analyzer { + return &analyzer{cfg: cfg, targets: targets} +} + +type analyzer struct { + cfg conflict.Config + targets TargetResolver + // TODO: cache resolved target sets per batch ID so in-flight batches + // compared against successive arrivals pay only one resolution each. Consider + // a TTL for high-traffic queues where trunk moves fast, and a max-size cap. +} + +// Analyze returns one ConflictTypeTargetOverlap Conflict per in-flight batch +// whose changed build targets overlap with batch, preserving the in-flight +// order. A batch that affects no targets conflicts with nothing. +func (a *analyzer) Analyze(ctx context.Context, batch entity.Batch, inFlight []entity.Batch) ([]entity.Conflict, error) { + if len(inFlight) == 0 { + return nil, nil + } + + // TODO: when TargetResolver fails, fall back to a queue-configured + // analyzer (all or none) instead of propagating the error. The queue config + // decides whether a Tango outage over-serializes (all) or maximizes + // parallelism (none). + candidate, err := a.resolve(ctx, batch) + if err != nil { + return nil, fmt.Errorf("failed to resolve targets for batch %s: %w", batch.ID, err) + } + if len(candidate) == 0 { + return nil, nil + } + + var conflicts []entity.Conflict + for _, other := range inFlight { + keys, err := a.resolve(ctx, other) + if err != nil { + return nil, fmt.Errorf("failed to resolve targets for batch %s: %w", other.ID, err) + } + if intersects(candidate, keys) { + conflicts = append(conflicts, entity.Conflict{ + BatchID: other.ID, + Type: entity.ConflictTypeTargetOverlap, + }) + } + } + return conflicts, nil +} + +// resolve returns the set of build targets the batch affects. +func (a *analyzer) resolve(ctx context.Context, batch entity.Batch) (map[string]struct{}, error) { + targets, err := a.targets.ChangedTargets(ctx, batch) + if err != nil { + return nil, err + } + + keys := make(map[string]struct{}, len(targets)) + for _, t := range targets { + keys[t] = struct{}{} + } + return keys, nil +} + +// intersects reports whether the two sets share any element. +func intersects(a, b map[string]struct{}) bool { + if len(b) < len(a) { + a, b = b, a + } + for k := range a { + if _, ok := b[k]; ok { + return true + } + } + return false +} diff --git a/submitqueue/extension/conflict/tango/tango_test.go b/submitqueue/extension/conflict/tango/tango_test.go new file mode 100644 index 00000000..5cbd21e8 --- /dev/null +++ b/submitqueue/extension/conflict/tango/tango_test.go @@ -0,0 +1,166 @@ +// Copyright (c) 2026 Uber Technologies, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package tango + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/uber/submitqueue/submitqueue/entity" + "github.com/uber/submitqueue/submitqueue/extension/conflict" +) + +// fakeResolver is an in-test TargetResolver that returns pre-configured target +// sets per batch ID. +type fakeResolver struct { + targets map[string][]string + err error +} + +func newFakeResolver() *fakeResolver { + return &fakeResolver{targets: make(map[string][]string)} +} + +func (f *fakeResolver) set(batchID string, targets ...string) *fakeResolver { + f.targets[batchID] = targets + return f +} + +func (f *fakeResolver) failWith(err error) *fakeResolver { + f.err = err + return f +} + +func (f *fakeResolver) ChangedTargets(_ context.Context, batch entity.Batch) ([]string, error) { + if f.err != nil { + return nil, f.err + } + return f.targets[batch.ID], nil +} + +func cfg() conflict.Config { + return conflict.Config{QueueName: "test-queue"} +} + +func TestAnalyze(t *testing.T) { + tests := []struct { + name string + candidate string + candTargets []string + inFlight []struct { + id string + targets []string + } + wantBatches []string + }{ + { + name: "overlap on a shared target conflicts", + candidate: "cand", + candTargets: []string{"//foo:lib", "//bar:lib"}, + inFlight: []struct { + id string + targets []string + }{ + {id: "x", targets: []string{"//bar:lib", "//baz:lib"}}, + }, + wantBatches: []string{"x"}, + }, + { + name: "disjoint targets do not conflict", + candidate: "cand", + candTargets: []string{"//foo:lib"}, + inFlight: []struct { + id string + targets []string + }{ + {id: "x", targets: []string{"//bar:lib"}}, + }, + wantBatches: nil, + }, + { + name: "only overlapping in-flight batches are reported, in order", + candidate: "cand", + candTargets: []string{"//foo:lib"}, + inFlight: []struct { + id string + targets []string + }{ + {id: "x", targets: []string{"//foo:lib"}}, + {id: "y", targets: []string{"//bar:lib"}}, + {id: "z", targets: []string{"//foo:lib"}}, + }, + wantBatches: []string{"x", "z"}, + }, + { + name: "candidate with no targets conflicts with nothing", + candidate: "cand", + candTargets: nil, + inFlight: []struct { + id string + targets []string + }{ + {id: "x", targets: []string{"//foo:lib"}}, + }, + wantBatches: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + resolver := newFakeResolver().set(tt.candidate, tt.candTargets...) + inFlight := make([]entity.Batch, 0, len(tt.inFlight)) + for _, f := range tt.inFlight { + resolver.set(f.id, f.targets...) + inFlight = append(inFlight, entity.Batch{ID: f.id}) + } + + got, err := New(cfg(), resolver).Analyze(context.Background(), entity.Batch{ID: tt.candidate}, inFlight) + require.NoError(t, err) + + var ids []string + for _, c := range got { + assert.Equal(t, entity.ConflictTypeTargetOverlap, c.Type) + ids = append(ids, c.BatchID) + } + assert.Equal(t, tt.wantBatches, ids) + }) + } +} + +func TestAnalyze_EmptyInFlight(t *testing.T) { + got, err := New(cfg(), newFakeResolver()).Analyze(context.Background(), entity.Batch{ID: "cand"}, nil) + require.NoError(t, err) + assert.Empty(t, got) +} + +func TestAnalyze_ResolverError(t *testing.T) { + sentinel := errors.New("tango unavailable") + + t.Run("candidate resolution fails", func(t *testing.T) { + resolver := newFakeResolver().failWith(sentinel) + _, err := New(cfg(), resolver).Analyze(context.Background(), entity.Batch{ID: "cand"}, []entity.Batch{{ID: "x"}}) + require.ErrorIs(t, err, sentinel) + }) + + t.Run("in-flight resolution fails", func(t *testing.T) { + resolver := newFakeResolver().set("cand", "//foo:lib").failWith(sentinel) + _, err := New(cfg(), resolver).Analyze(context.Background(), entity.Batch{ID: "cand"}, []entity.Batch{{ID: "x"}}) + require.ErrorIs(t, err, sentinel) + }) +}