Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
load("@rules_go//go:def.bzl", "go_library", "go_test")

go_library(
name = "go_default_library",
srcs = ["targetoverlap.go"],
importpath = "github.com/uber/submitqueue/submitqueue/extension/dependency/conflict/targetoverlap",
visibility = ["//visibility:public"],
deps = [
"//submitqueue/entity:go_default_library",
"//submitqueue/extension/conflict:go_default_library",
"//submitqueue/extension/dependency/resolver:go_default_library",
],
)

go_test(
name = "go_default_test",
srcs = ["targetoverlap_test.go"],
embed = [":go_default_library"],
deps = [
"//submitqueue/entity:go_default_library",
"//submitqueue/extension/conflict:go_default_library",
"//submitqueue/extension/dependency/resolver:go_default_library",
"@com_github_stretchr_testify//assert:go_default_library",
"@com_github_stretchr_testify//require:go_default_library",
],
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
// 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 targetoverlap 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 resolver.TargetResolver,
// keeping the analyzer independent of any particular target-resolution backend.
package targetoverlap

import (
"context"
"fmt"

"github.com/uber/submitqueue/submitqueue/entity"
"github.com/uber/submitqueue/submitqueue/extension/conflict"
"github.com/uber/submitqueue/submitqueue/extension/dependency/resolver"
)

// 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 resolver.TargetResolver) conflict.Analyzer {
return &analyzer{cfg: cfg, targets: targets}
}

type analyzer struct {
cfg conflict.Config
targets resolver.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 resolver 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.Name] = 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
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
// 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 targetoverlap

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"
"github.com/uber/submitqueue/submitqueue/extension/dependency/resolver"
)

// 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) ([]resolver.Target, error) {
if f.err != nil {
return nil, f.err
}
names := f.targets[batch.ID]
targets := make([]resolver.Target, len(names))
for i, n := range names {
targets[i] = resolver.Target{Name: n}
}
return targets, 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) {
r := newFakeResolver().set(tt.candidate, tt.candTargets...)
inFlight := make([]entity.Batch, 0, len(tt.inFlight))
for _, f := range tt.inFlight {
r.set(f.id, f.targets...)
inFlight = append(inFlight, entity.Batch{ID: f.id})
}

got, err := New(cfg(), r).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("resolver unavailable")

t.Run("candidate resolution fails", func(t *testing.T) {
r := newFakeResolver().failWith(sentinel)
_, err := New(cfg(), r).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) {
r := newFakeResolver().set("cand", "//foo:lib").failWith(sentinel)
_, err := New(cfg(), r).Analyze(context.Background(), entity.Batch{ID: "cand"}, []entity.Batch{{ID: "x"}})
require.ErrorIs(t, err, sentinel)
})
}
9 changes: 9 additions & 0 deletions submitqueue/extension/dependency/resolver/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
load("@rules_go//go:def.bzl", "go_library")

go_library(
name = "go_default_library",
srcs = ["targetresolver.go"],
importpath = "github.com/uber/submitqueue/submitqueue/extension/dependency/resolver",
visibility = ["//visibility:public"],
deps = ["//submitqueue/entity:go_default_library"],
)
43 changes: 43 additions & 0 deletions submitqueue/extension/dependency/resolver/targetresolver.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
// 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 resolver defines the TargetResolver interface for resolving the set
// of build targets a batch affects. The interface is deliberately free of
// Tango wire types so that each deployment can provide its own adapter against
// whatever proto import path its monorepo uses — the analyzer sees only batch
// identity in and targets out.
package resolver

import (
"context"

"github.com/uber/submitqueue/submitqueue/entity"
)

// Target is a build target a batch affects.
type Target struct {
// Name identifies the target (e.g. "//service/foo:lib").
Name string
// Attributes carries backend-specific metadata the analyzer does not
// interpret today. Future consumers (e.g. conflict relaxation) can read
// keys like "distance" or "rule_type" without an interface change.
Attributes map[string]string
}

// 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) ([]Target, error)
}
Loading