Skip to content

Commit 16fa2ce

Browse files
committed
feat(conflict): Resolve the Tango base revision from a git workspace
1 parent 6ea7eb7 commit 16fa2ce

4 files changed

Lines changed: 353 additions & 1 deletion

File tree

‎submitqueue/extension/conflict/tango/README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,4 +20,4 @@ Each signature is computed against trunk at its own analysis time, without the o
2020

2121
## Injected pieces
2222

23-
The analyzer owns no connection. A deployment supplies the Tango client adapter, which collects the response and resolves each target's label, and a function that returns the commit SHA of the target branch tip a new batch is analyzed against. The changes of a batch come from the changeset resolver, the signature store is bound to one queue, and one signature cache can be shared by every queue's analyzer. The MySQL implementations of the signature and dictionary stores are in `mysql/`.
23+
The analyzer owns no connection. A deployment supplies the Tango client adapter, which collects the response and resolves each target's label, and a function that returns the commit SHA of the target branch tip a new batch is analyzed against. `baserevision.FromGit` builds that function over any `gitworkspace.Factory`. The changes of a batch come from the changeset resolver, the signature store is bound to one queue, and one signature cache can be shared by every queue's analyzer. The MySQL implementations of the signature and dictionary stores are in `mysql/`.
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
load("@rules_go//go:def.bzl", "go_library", "go_test")
2+
3+
go_library(
4+
name = "go_default_library",
5+
srcs = ["baserevision.go"],
6+
importpath = "github.com/uber/submitqueue/submitqueue/extension/conflict/tango/baserevision",
7+
visibility = ["//visibility:public"],
8+
deps = ["//platform/extension/gitworkspace:go_default_library"],
9+
)
10+
11+
go_test(
12+
name = "go_default_test",
13+
srcs = ["baserevision_test.go"],
14+
embed = [":go_default_library"],
15+
deps = [
16+
"//platform/extension/gitworkspace:go_default_library",
17+
"//platform/extension/gitworkspace/mock:go_default_library",
18+
"@com_github_stretchr_testify//assert:go_default_library",
19+
"@com_github_stretchr_testify//require:go_default_library",
20+
"@org_uber_go_mock//gomock:go_default_library",
21+
],
22+
)
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
// Copyright (c) 2026 Uber Technologies, Inc.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
// Package baserevision resolves the commit a new batch is analyzed against by
16+
// asking a git workspace for the tip of the queue's target branch. Any
17+
// gitworkspace.Factory works, so where git runs is the deployment's choice.
18+
package baserevision
19+
20+
import (
21+
"context"
22+
"fmt"
23+
"regexp"
24+
"strings"
25+
"sync"
26+
27+
"github.com/uber/submitqueue/platform/extension/gitworkspace"
28+
)
29+
30+
const defaultRemote = "origin"
31+
32+
// fullCommitSHA matches the SHA-1 and SHA-256 object formats. Tango needs the
33+
// full SHA, so an abbreviated one is rejected.
34+
var fullCommitSHA = regexp.MustCompile(`^([0-9a-f]{40}|[0-9a-f]{64})$`)
35+
36+
// Target locates the branch whose tip is a queue's base revision.
37+
type Target struct {
38+
// Repo identifies the repository to the workspace factory.
39+
Repo string
40+
41+
// Remote is the git remote the branch is fetched from. It defaults to
42+
// origin.
43+
Remote string
44+
45+
// Branch is the target branch.
46+
Branch string
47+
}
48+
49+
// FromGit returns a function for tango.Params.BaseRevision that fetches the
50+
// target branch of the queue and returns its tip as a full commit SHA, as of
51+
// the call. A queue without a target, or any git failure, is an error. Calls
52+
// that share a Repo are serialized, because concurrent fetches into one clone
53+
// can fail on a ref lock and overwrite each other's FETCH_HEAD.
54+
func FromGit(factory gitworkspace.Factory, targets map[string]Target) func(ctx context.Context, queue string) (string, error) {
55+
locks := make(map[string]*sync.Mutex, len(targets))
56+
for _, target := range targets {
57+
locks[target.Repo] = &sync.Mutex{}
58+
}
59+
60+
return func(ctx context.Context, queue string) (string, error) {
61+
target, ok := targets[queue]
62+
if !ok {
63+
return "", fmt.Errorf("no target branch configured for queue %s", queue)
64+
}
65+
remote := target.Remote
66+
if remote == "" {
67+
remote = defaultRemote
68+
}
69+
70+
lock := locks[target.Repo]
71+
lock.Lock()
72+
defer lock.Unlock()
73+
74+
workspace, err := factory.For(ctx, target.Repo)
75+
if err != nil {
76+
return "", fmt.Errorf("failed to open a workspace for repo %s: %w", target.Repo, err)
77+
}
78+
defer workspace.Close()
79+
80+
outputs, err := workspace.Exec([]gitworkspace.Command{
81+
{Alias: "fetch", Bin: "git", Args: []string{"fetch", remote, target.Branch}},
82+
{Alias: "tip", Bin: "git", Args: []string{"rev-parse", "FETCH_HEAD^{commit}"}},
83+
})
84+
if err != nil {
85+
return "", fmt.Errorf("failed to run git in repo %s: %w", target.Repo, err)
86+
}
87+
for _, out := range outputs {
88+
if out.ExitCode != 0 {
89+
return "", fmt.Errorf("git %s failed with exit code %d: %s", out.Alias, out.ExitCode, strings.TrimSpace(out.Stderr))
90+
}
91+
}
92+
if len(outputs) != 2 {
93+
return "", fmt.Errorf("expected 2 git outputs, got %d", len(outputs))
94+
}
95+
96+
sha := strings.TrimSpace(outputs[1].Stdout)
97+
if !fullCommitSHA.MatchString(sha) {
98+
return "", fmt.Errorf("git returned %q, not a full commit SHA", sha)
99+
}
100+
return sha, nil
101+
}
102+
}
Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
// Copyright (c) 2026 Uber Technologies, Inc.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package baserevision
16+
17+
import (
18+
"context"
19+
"errors"
20+
"runtime"
21+
"sync"
22+
"sync/atomic"
23+
"testing"
24+
25+
"github.com/stretchr/testify/assert"
26+
"github.com/stretchr/testify/require"
27+
"go.uber.org/mock/gomock"
28+
29+
"github.com/uber/submitqueue/platform/extension/gitworkspace"
30+
"github.com/uber/submitqueue/platform/extension/gitworkspace/mock"
31+
)
32+
33+
const (
34+
testQueue = "monorepo"
35+
testRepo = "org/repo"
36+
testSHA = "9f2c1d0e3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d"
37+
)
38+
39+
var errTestFailure = errors.New("dependency down")
40+
41+
func TestFromGit(t *testing.T) {
42+
tests := []struct {
43+
name string
44+
queue string
45+
target Target
46+
factoryOK bool
47+
execOut []gitworkspace.Output
48+
execErr error
49+
wantArgs [][]string
50+
want string
51+
wantErr bool
52+
}{
53+
{
54+
name: "returns the fetched tip",
55+
queue: testQueue,
56+
target: Target{Repo: testRepo, Remote: "upstream", Branch: "main"},
57+
factoryOK: true,
58+
execOut: []gitworkspace.Output{{Alias: "fetch"}, {Alias: "tip", Stdout: testSHA + "\n"}},
59+
wantArgs: [][]string{{"fetch", "upstream", "main"}, {"rev-parse", "FETCH_HEAD^{commit}"}},
60+
want: testSHA,
61+
},
62+
{
63+
name: "defaults the remote to origin",
64+
queue: testQueue,
65+
target: Target{Repo: testRepo, Branch: "main"},
66+
factoryOK: true,
67+
execOut: []gitworkspace.Output{{Alias: "fetch"}, {Alias: "tip", Stdout: testSHA}},
68+
wantArgs: [][]string{{"fetch", "origin", "main"}, {"rev-parse", "FETCH_HEAD^{commit}"}},
69+
want: testSHA,
70+
},
71+
{
72+
name: "queue without a target fails",
73+
queue: "other",
74+
target: Target{Repo: testRepo, Branch: "main"},
75+
wantErr: true,
76+
},
77+
{
78+
name: "workspace that cannot be opened fails",
79+
queue: testQueue,
80+
target: Target{Repo: testRepo, Branch: "main"},
81+
wantErr: true,
82+
},
83+
{
84+
name: "batch that cannot run fails",
85+
queue: testQueue,
86+
target: Target{Repo: testRepo, Branch: "main"},
87+
factoryOK: true,
88+
execErr: errTestFailure,
89+
wantArgs: [][]string{{"fetch", "origin", "main"}, {"rev-parse", "FETCH_HEAD^{commit}"}},
90+
wantErr: true,
91+
},
92+
{
93+
name: "failed fetch fails",
94+
queue: testQueue,
95+
target: Target{Repo: testRepo, Branch: "main"},
96+
factoryOK: true,
97+
execOut: []gitworkspace.Output{{Alias: "fetch", ExitCode: 128, Stderr: "auth"}, {Alias: "tip", Skipped: true, ExitCode: -1}},
98+
wantArgs: [][]string{{"fetch", "origin", "main"}, {"rev-parse", "FETCH_HEAD^{commit}"}},
99+
wantErr: true,
100+
},
101+
{
102+
name: "abbreviated SHA fails",
103+
queue: testQueue,
104+
target: Target{Repo: testRepo, Branch: "main"},
105+
factoryOK: true,
106+
execOut: []gitworkspace.Output{{Alias: "fetch"}, {Alias: "tip", Stdout: "9f2c1d0\n"}},
107+
wantArgs: [][]string{{"fetch", "origin", "main"}, {"rev-parse", "FETCH_HEAD^{commit}"}},
108+
wantErr: true,
109+
},
110+
{
111+
name: "missing output fails",
112+
queue: testQueue,
113+
target: Target{Repo: testRepo, Branch: "main"},
114+
factoryOK: true,
115+
execOut: []gitworkspace.Output{{Alias: "fetch"}},
116+
wantArgs: [][]string{{"fetch", "origin", "main"}, {"rev-parse", "FETCH_HEAD^{commit}"}},
117+
wantErr: true,
118+
},
119+
}
120+
for _, tt := range tests {
121+
t.Run(tt.name, func(t *testing.T) {
122+
ctrl := gomock.NewController(t)
123+
factory := mock.NewMockFactory(ctrl)
124+
if tt.queue == testQueue {
125+
if tt.factoryOK {
126+
workspace := mock.NewMockWorkspace(ctrl)
127+
factory.EXPECT().For(gomock.Any(), testRepo).Return(workspace, nil)
128+
workspace.EXPECT().Exec(gomock.Cond(func(commands []gitworkspace.Command) bool {
129+
if len(commands) != len(tt.wantArgs) {
130+
return false
131+
}
132+
for i, command := range commands {
133+
if command.Bin != "git" || !assert.ObjectsAreEqual(tt.wantArgs[i], command.Args) {
134+
return false
135+
}
136+
}
137+
return true
138+
})).Return(tt.execOut, tt.execErr)
139+
workspace.EXPECT().Close().Return(nil)
140+
} else {
141+
factory.EXPECT().For(gomock.Any(), testRepo).Return(nil, errTestFailure)
142+
}
143+
}
144+
145+
got, err := FromGit(factory, map[string]Target{testQueue: tt.target})(context.Background(), tt.queue)
146+
147+
if tt.wantErr {
148+
require.Error(t, err)
149+
return
150+
}
151+
require.NoError(t, err)
152+
assert.Equal(t, tt.want, got)
153+
})
154+
}
155+
}
156+
157+
func TestFromGit_RepoLocks(t *testing.T) {
158+
validOutputs := []gitworkspace.Output{{Alias: "fetch"}, {Alias: "tip", Stdout: testSHA}}
159+
160+
t.Run("calls on one repo never overlap", func(t *testing.T) {
161+
ctrl := gomock.NewController(t)
162+
factory := mock.NewMockFactory(ctrl)
163+
workspace := mock.NewMockWorkspace(ctrl)
164+
factory.EXPECT().For(gomock.Any(), testRepo).Return(workspace, nil).AnyTimes()
165+
workspace.EXPECT().Close().Return(nil).AnyTimes()
166+
var inFlight, maxInFlight atomic.Int32
167+
workspace.EXPECT().Exec(gomock.Any()).DoAndReturn(func([]gitworkspace.Command) ([]gitworkspace.Output, error) {
168+
n := inFlight.Add(1)
169+
for m := maxInFlight.Load(); n > m && !maxInFlight.CompareAndSwap(m, n); m = maxInFlight.Load() {
170+
}
171+
runtime.Gosched()
172+
inFlight.Add(-1)
173+
return validOutputs, nil
174+
}).AnyTimes()
175+
baseRevision := FromGit(factory, map[string]Target{
176+
"main-queue": {Repo: testRepo, Branch: "main"},
177+
"release-queue": {Repo: testRepo, Branch: "release"},
178+
})
179+
180+
var wg sync.WaitGroup
181+
for i := 0; i < 32; i++ {
182+
queue := "main-queue"
183+
if i%2 == 1 {
184+
queue = "release-queue"
185+
}
186+
wg.Add(1)
187+
go func() {
188+
defer wg.Done()
189+
_, err := baseRevision(context.Background(), queue)
190+
assert.NoError(t, err)
191+
}()
192+
}
193+
wg.Wait()
194+
195+
assert.Equal(t, int32(1), maxInFlight.Load())
196+
})
197+
198+
t.Run("calls on different repos do not block each other", func(t *testing.T) {
199+
ctrl := gomock.NewController(t)
200+
factory := mock.NewMockFactory(ctrl)
201+
entered := map[string]chan struct{}{"repo-x": make(chan struct{}), "repo-y": make(chan struct{})}
202+
for repo, other := range map[string]string{"repo-x": "repo-y", "repo-y": "repo-x"} {
203+
workspace := mock.NewMockWorkspace(ctrl)
204+
factory.EXPECT().For(gomock.Any(), repo).Return(workspace, nil)
205+
workspace.EXPECT().Exec(gomock.Any()).DoAndReturn(func([]gitworkspace.Command) ([]gitworkspace.Output, error) {
206+
close(entered[repo])
207+
<-entered[other]
208+
return validOutputs, nil
209+
})
210+
workspace.EXPECT().Close().Return(nil)
211+
}
212+
baseRevision := FromGit(factory, map[string]Target{
213+
"queue-x": {Repo: "repo-x", Branch: "main"},
214+
"queue-y": {Repo: "repo-y", Branch: "main"},
215+
})
216+
217+
var wg sync.WaitGroup
218+
for _, queue := range []string{"queue-x", "queue-y"} {
219+
wg.Add(1)
220+
go func() {
221+
defer wg.Done()
222+
_, err := baseRevision(context.Background(), queue)
223+
assert.NoError(t, err)
224+
}()
225+
}
226+
wg.Wait()
227+
})
228+
}

0 commit comments

Comments
 (0)