Skip to content

Commit fbf34c3

Browse files
committed
feat: add concurrency tuning support
add concurrency manager module update documentation add args implement in function logic
1 parent e0b2b81 commit fbf34c3

File tree

9 files changed

+262
-66
lines changed

9 files changed

+262
-66
lines changed

README.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,14 +53,17 @@ If mandatory arguments are not provided, the program will prompt for them.
5353
| Argument | Environment Variable equivalent | Mandatory | Description |
5454
|----------|-------------------------------|-----------|-------------|
5555
| `--help` or `-h` | N/A | No | Show help message and exit |
56-
| `--version` or `-V` | N/A | No | Show version information and exit |
56+
| `--version` | N/A | No | Show version information and exit |
5757
| `--verbose` or `-v` | N/A | No | Enable verbose output |
5858
| `--dry-run` | N/A | No | Perform a dry run without making any changes |
5959
| `--source-url` | `SOURCE_GITLAB_URL` | Yes | URL of the source GitLab instance |
6060
| `--source-token` | `SOURCE_GITLAB_TOKEN` | No | Access token for the source GitLab instance |
6161
| `--destination-url` | `DESTINATION_GITLAB_URL` | Yes | URL of the destination GitLab instance |
6262
| `--destination-token` | `DESTINATION_GITLAB_TOKEN` | Yes | Access token for the destination GitLab instance |
6363
| `--mirror-mapping` | `MIRROR_MAPPING` | Yes | Path to a JSON file containing the mirror mapping |
64+
| `--concurrency` or `-C` | N/A | No | Max number of concurrent requests to the GitLab API (default: 10) |
65+
| `--timeout` or `-t` | N/A | No | Timeout for GitLab API requests in seconds (default: 30) |
66+
| `--retry` or `-r` | N/A | No | Number of retries for failed GitLab API requests (does not apply to GraphQL requests) (default: 3) |
6467

6568
## Example
6669

cmd/main.go

Lines changed: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"os"
77
"path/filepath"
88
"strings"
9+
"time"
910

1011
"gitlab-sync/mirroring"
1112
"gitlab-sync/utils"
@@ -20,15 +21,36 @@ var (
2021
func main() {
2122
var args utils.ParserArgs
2223
var mirrorMappingPath string
24+
var timeout int
2325

2426
var rootCmd = &cobra.Command{
25-
Use: "gitlab-sync",
26-
Short: "Copy and enable mirroring of gitlab projects and groups",
27-
Long: "Fully customizable gitlab repositories and groups mirroring between two (or one) gitlab instances.",
27+
Use: "gitlab-sync",
28+
Version: version,
29+
Short: "Copy and enable mirroring of gitlab projects and groups",
30+
Long: "Fully customizable gitlab repositories and groups mirroring between two (or one) gitlab instances.",
2831
Run: func(cmd *cobra.Command, cmdArgs []string) {
29-
if args.Version {
30-
fmt.Printf("gitlab-sync version: %s\n", version)
31-
return
32+
// Set the concurrency limit
33+
if args.Concurrency == -1 {
34+
args.Concurrency = 10000
35+
} else if args.Concurrency == 0 {
36+
log.Fatal("concurrency limit must be -1 (no limit) or strictly greater than 0")
37+
}
38+
utils.ConcurrencyManager.SetLimit(args.Concurrency)
39+
40+
// Obtain the retry count
41+
if args.Retry == -1 {
42+
args.Retry = 10000
43+
} else if args.Retry == 0 {
44+
log.Fatal("retry count must be -1 (no limit) or strictly greater than 0")
45+
}
46+
47+
// Set the timeout for GitLab API requests
48+
if timeout == -1 {
49+
args.Timeout = time.Duration(10000 * time.Second)
50+
} else if timeout == 0 {
51+
log.Fatal("timeout must be -1 (no limit) or strictly greater than 0")
52+
} else {
53+
args.Timeout = time.Duration(timeout) * time.Second
3254
}
3355

3456
utils.SetVerbose(args.Verbose)
@@ -65,7 +87,6 @@ func main() {
6587
},
6688
}
6789

68-
rootCmd.Flags().BoolVarP(&args.Version, "version", "V", false, "Show version")
6990
rootCmd.Flags().StringVar(&args.SourceGitlabURL, "source-url", os.Getenv("SOURCE_GITLAB_URL"), "Source GitLab URL")
7091
rootCmd.Flags().StringVar(&args.SourceGitlabToken, "source-token", os.Getenv("SOURCE_GITLAB_TOKEN"), "Source GitLab Token")
7192
rootCmd.Flags().StringVar(&args.DestinationGitlabURL, "destination-url", os.Getenv("DESTINATION_GITLAB_URL"), "Destination GitLab URL")
@@ -74,6 +95,9 @@ func main() {
7495
rootCmd.Flags().BoolVarP(&args.NoPrompt, "no-prompt", "n", strings.TrimSpace(os.Getenv("NO_PROMPT")) != "", "Disable prompting for missing values")
7596
rootCmd.Flags().StringVar(&mirrorMappingPath, "mirror-mapping", os.Getenv("MIRROR_MAPPING"), "Path to the mirror mapping file")
7697
rootCmd.Flags().BoolVar(&args.DryRun, "dry-run", false, "Perform a dry run without making any changes")
98+
rootCmd.Flags().IntVarP(&timeout, "timeout", "t", 30, "Timeout in seconds for GitLab API requests")
99+
rootCmd.Flags().IntVarP(&args.Concurrency, "concurrency", "c", 10, "Max number of concurrent requests")
100+
rootCmd.Flags().IntVarP(&args.Retry, "retry", "r", 3, "Number of retries for failed requests")
77101

78102
if err := rootCmd.Execute(); err != nil {
79103
fmt.Println(err)

mirroring/instance.go

Lines changed: 45 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
package mirroring
22

33
import (
4+
"net/http"
45
"sync"
6+
"time"
57

68
"gitlab-sync/utils"
79

@@ -17,8 +19,18 @@ type GitlabInstance struct {
1719
GraphQLClient *utils.GraphQLClient
1820
}
1921

20-
func newGitlabInstance(gitlabURL string, gitlabToken string) (*GitlabInstance, error) {
21-
gitlabClient, err := gitlab.NewClient(gitlabToken, gitlab.WithBaseURL(gitlabURL))
22+
func newGitlabInstance(gitlabURL string, gitlabToken string, timeout time.Duration, maxRetries int) (*GitlabInstance, error) {
23+
// Create a custom HTTP client with a timeout
24+
httpClient := &http.Client{
25+
Timeout: timeout,
26+
Transport: &retryTransport{
27+
Base: http.DefaultTransport,
28+
MaxRetries: maxRetries,
29+
},
30+
}
31+
32+
// Initialize the GitLab client with the custom HTTP client
33+
gitlabClient, err := gitlab.NewClient(gitlabToken, gitlab.WithBaseURL(gitlabURL), gitlab.WithHTTPClient(httpClient))
2234
if err != nil {
2335
return nil, err
2436
}
@@ -33,31 +45,56 @@ func newGitlabInstance(gitlabURL string, gitlabToken string) (*GitlabInstance, e
3345
return gitlabInstance, nil
3446
}
3547

48+
// Add a project to the GitLabInstance
3649
func (g *GitlabInstance) addProject(projectPath string, project *gitlab.Project) {
3750
g.muProjects.Lock()
3851
defer g.muProjects.Unlock()
3952
g.Projects[projectPath] = project
4053
}
4154

55+
// Get a project from the GitLabInstance
4256
func (g *GitlabInstance) getProject(projectPath string) *gitlab.Project {
4357
g.muProjects.RLock()
4458
defer g.muProjects.RUnlock()
45-
var project *gitlab.Project
46-
project, exists := g.Projects[projectPath]
47-
if !exists {
48-
project = nil
49-
}
50-
return project
59+
return g.Projects[projectPath]
5160
}
5261

62+
// Add a group to the GitLabInstance
5363
func (g *GitlabInstance) addGroup(groupPath string, group *gitlab.Group) {
5464
g.muGroups.Lock()
5565
defer g.muGroups.Unlock()
5666
g.Groups[groupPath] = group
5767
}
5868

69+
// Get a group from the GitLabInstance
5970
func (g *GitlabInstance) getGroup(groupPath string) *gitlab.Group {
6071
g.muGroups.RLock()
6172
defer g.muGroups.RUnlock()
6273
return g.Groups[groupPath]
6374
}
75+
76+
// retryTransport wraps the default HTTP transport to add automatic retries
77+
type retryTransport struct {
78+
Base http.RoundTripper
79+
MaxRetries int
80+
}
81+
82+
// RoundTrip implements the RoundTripper interface for retryTransport
83+
func (rt *retryTransport) RoundTrip(req *http.Request) (*http.Response, error) {
84+
var resp *http.Response
85+
var err error
86+
87+
// Retry the request up to MaxRetries times
88+
for i := 0; i <= rt.MaxRetries; i++ {
89+
resp, err = rt.Base.RoundTrip(req)
90+
if err == nil && resp.StatusCode < http.StatusInternalServerError {
91+
// If the request succeeded or returned a non-server-error status, return the response
92+
return resp, nil
93+
}
94+
95+
// Retry only on specific server errors or network issues
96+
time.Sleep(time.Duration(i) * time.Second) // Exponential backoff
97+
}
98+
99+
return resp, err
100+
}

mirroring/instance_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ func TestNewGitlabInstance(t *testing.T) {
1010
gitlabURL := "https://gitlab.example.com"
1111
gitlabToken := "test-token"
1212

13-
instance, err := newGitlabInstance(gitlabURL, gitlabToken)
13+
instance, err := newGitlabInstance(gitlabURL, gitlabToken, 10, 3)
1414
if err != nil {
1515
t.Fatalf("expected no error, got %v", err)
1616
}

mirroring/main.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,12 @@ import (
1010
)
1111

1212
func MirrorGitlabs(gitlabMirrorArgs *utils.ParserArgs) error {
13-
sourceGitlabInstance, err := newGitlabInstance(gitlabMirrorArgs.SourceGitlabURL, gitlabMirrorArgs.SourceGitlabToken)
13+
sourceGitlabInstance, err := newGitlabInstance(gitlabMirrorArgs.SourceGitlabURL, gitlabMirrorArgs.SourceGitlabToken, gitlabMirrorArgs.Timeout, gitlabMirrorArgs.Retry)
1414
if err != nil {
1515
return err
1616
}
1717

18-
destinationGitlabInstance, err := newGitlabInstance(gitlabMirrorArgs.DestinationGitlabURL, gitlabMirrorArgs.DestinationGitlabToken)
18+
destinationGitlabInstance, err := newGitlabInstance(gitlabMirrorArgs.DestinationGitlabURL, gitlabMirrorArgs.DestinationGitlabToken, gitlabMirrorArgs.Timeout, gitlabMirrorArgs.Retry)
1919
if err != nil {
2020
return err
2121
}

mirroring/post.go

Lines changed: 89 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ func createGroups(sourceGitlab *GitlabInstance, destinationGitlab *GitlabInstanc
6969

7070
func createProjects(sourceGitlab *GitlabInstance, destinationGitlab *GitlabInstance, mirrorMapping *utils.MirrorMapping) error {
7171
utils.LogVerbose("Creating projects in destination GitLab instance")
72+
7273
// Reverse the mirror mapping to get the source project path for each destination project
7374
reversedMirrorMap := make(map[string]string, len(mirrorMapping.Projects))
7475
for sourceProjectPath, projectOptions := range mirrorMapping.Projects {
@@ -81,10 +82,6 @@ func createProjects(sourceGitlab *GitlabInstance, destinationGitlab *GitlabInsta
8182
// Create a channel to collect errors
8283
errorChan := make(chan error, len(reversedMirrorMap))
8384

84-
// Create a channel to limit the number of concurrent goroutines
85-
concurrencyLimit := 10
86-
sem := make(chan struct{}, concurrencyLimit)
87-
8885
for destinationProjectPath, sourceProjectPath := range reversedMirrorMap {
8986
utils.LogVerbosef("Mirroring project from source %s to destination %s", sourceProjectPath, destinationProjectPath)
9087
sourceProject := sourceGitlab.Projects[sourceProjectPath]
@@ -93,13 +90,9 @@ func createProjects(sourceGitlab *GitlabInstance, destinationGitlab *GitlabInsta
9390
continue
9491
}
9592
wg.Add(1)
96-
// Acquire a token from the semaphore
97-
sem <- struct{}{}
9893

9994
go func(sourcePath string, destinationPath string) {
10095
defer wg.Done()
101-
// Release the token back to the semaphore
102-
defer func() { <-sem }()
10396

10497
// Retrieve the corresponding project creation options from the mirror mapping
10598
projectCreationOptions, ok := mirrorMapping.Projects[sourcePath]
@@ -136,42 +129,64 @@ func createProjects(sourceGitlab *GitlabInstance, destinationGitlab *GitlabInsta
136129
}(sourceProjectPath, destinationProjectPath)
137130
}
138131

139-
// Wait for all goroutines to finish
140132
wg.Wait()
141133
close(errorChan)
142134

143135
return utils.MergeErrors(errorChan, 2)
144136
}
145137

146138
func (g *GitlabInstance) createProjectFromSource(sourceProject *gitlab.Project, copyOptions *utils.MirroringOptions) (*gitlab.Project, error) {
147-
projectCreationArgs := &gitlab.CreateProjectOptions{
148-
Name: &sourceProject.Name,
149-
Path: &sourceProject.Path,
150-
DefaultBranch: &sourceProject.DefaultBranch,
151-
Description: &sourceProject.Description,
152-
MirrorTriggerBuilds: gitlab.Ptr(copyOptions.MirrorTriggerBuilds),
153-
Mirror: gitlab.Ptr(true),
154-
Topics: &sourceProject.Topics,
155-
Visibility: gitlab.Ptr(gitlab.VisibilityValue(copyOptions.Visibility)),
156-
}
139+
// Create a wait group and error channel for error handling
140+
var wg sync.WaitGroup
141+
errorChan := make(chan error, 1)
142+
143+
// Define the API call logic
144+
apiFunc := func() error {
145+
projectCreationArgs := &gitlab.CreateProjectOptions{
146+
Name: &sourceProject.Name,
147+
Path: &sourceProject.Path,
148+
DefaultBranch: &sourceProject.DefaultBranch,
149+
Description: &sourceProject.Description,
150+
MirrorTriggerBuilds: &copyOptions.MirrorTriggerBuilds,
151+
Mirror: gitlab.Ptr(true),
152+
Topics: &sourceProject.Topics,
153+
Visibility: gitlab.Ptr(gitlab.VisibilityValue(copyOptions.Visibility)),
154+
}
157155

158-
utils.LogVerbosef("Retrieving project namespace ID for %s", copyOptions.DestinationPath)
159-
parentNamespaceId, err := g.getParentNamespaceID(copyOptions.DestinationPath)
160-
if err != nil {
161-
return nil, err
162-
} else if parentNamespaceId >= 0 {
163-
projectCreationArgs.NamespaceID = &parentNamespaceId
156+
utils.LogVerbosef("Retrieving project namespace ID for %s", copyOptions.DestinationPath)
157+
parentNamespaceId, err := g.getParentNamespaceID(copyOptions.DestinationPath)
158+
if err != nil {
159+
return err
160+
} else if parentNamespaceId >= 0 {
161+
projectCreationArgs.NamespaceID = &parentNamespaceId
162+
}
163+
164+
utils.LogVerbosef("Creating project %s in destination GitLab instance", copyOptions.DestinationPath)
165+
destinationProject, _, err := g.Gitlab.Projects.CreateProject(projectCreationArgs)
166+
if err != nil {
167+
return err
168+
}
169+
utils.LogVerbosef("Project %s created successfully", destinationProject.PathWithNamespace)
170+
g.addProject(copyOptions.DestinationPath, destinationProject)
171+
172+
return nil
164173
}
165174

166-
utils.LogVerbosef("Creating project %s in destination GitLab instance", copyOptions.DestinationPath)
167-
destinationProject, _, err := g.Gitlab.Projects.CreateProject(projectCreationArgs)
168-
if err != nil {
175+
// Increment the wait group counter and execute the API call
176+
wg.Add(1)
177+
go utils.ExecuteWithConcurrency(apiFunc, &wg, errorChan)
178+
179+
// Wait for the API call to complete
180+
wg.Wait()
181+
close(errorChan)
182+
183+
// Check for errors
184+
select {
185+
case err := <-errorChan:
169186
return nil, err
187+
default:
188+
return nil, nil
170189
}
171-
utils.LogVerbosef("Project %s created successfully", destinationProject.PathWithNamespace)
172-
g.addProject(copyOptions.DestinationPath, destinationProject)
173-
174-
return destinationProject, nil
175190
}
176191

177192
func (g *GitlabInstance) createGroupFromSource(sourceGroup *gitlab.Group, copyOptions *utils.MirroringOptions) (*gitlab.Group, error) {
@@ -219,6 +234,10 @@ func (g *GitlabInstance) mirrorReleases(sourceProject *gitlab.Project, destinati
219234
return fmt.Errorf("failed to fetch releases for source project %s: %s", sourceProject.PathWithNamespace, err)
220235
}
221236

237+
// Create a wait group and an error channel for handling API calls concurrently
238+
var wg sync.WaitGroup
239+
errorChan := make(chan error, len(sourceReleases))
240+
222241
// Iterate over each source release
223242
for _, release := range sourceReleases {
224243
// Check if the release already exists in the destination project
@@ -227,20 +246,48 @@ func (g *GitlabInstance) mirrorReleases(sourceProject *gitlab.Project, destinati
227246
continue
228247
}
229248

230-
utils.LogVerbosef("Mirroring release %s to project %s", release.TagName, destinationProject.PathWithNamespace)
249+
// Increment the wait group counter
250+
wg.Add(1)
231251

232-
// Create the release in the destination project
233-
_, _, err := g.Gitlab.Releases.CreateRelease(destinationProject.ID, &gitlab.CreateReleaseOptions{
234-
Name: gitlab.Ptr(release.Name),
235-
TagName: gitlab.Ptr(release.TagName),
236-
Description: gitlab.Ptr(release.Description),
237-
ReleasedAt: release.ReleasedAt,
238-
})
239-
if err != nil {
240-
utils.LogVerbosef("Failed to create release %s in project %s: %s", release.TagName, destinationProject.PathWithNamespace, err)
252+
// Define the API call logic for creating a release
253+
releaseToMirror := release // Capture the current release in the loop
254+
go utils.ExecuteWithConcurrency(func() error {
255+
utils.LogVerbosef("Mirroring release %s to project %s", releaseToMirror.TagName, destinationProject.PathWithNamespace)
256+
257+
// Create the release in the destination project
258+
_, _, err := g.Gitlab.Releases.CreateRelease(destinationProject.ID, &gitlab.CreateReleaseOptions{
259+
Name: &releaseToMirror.Name,
260+
TagName: &releaseToMirror.TagName,
261+
Description: &releaseToMirror.Description,
262+
ReleasedAt: releaseToMirror.ReleasedAt,
263+
})
264+
if err != nil {
265+
utils.LogVerbosef("Failed to create release %s in project %s: %s", releaseToMirror.TagName, destinationProject.PathWithNamespace, err)
266+
return fmt.Errorf("Failed to create release %s in project %s: %s", releaseToMirror.TagName, destinationProject.PathWithNamespace, err)
267+
}
268+
269+
return nil
270+
}, &wg, errorChan)
271+
}
272+
273+
// Wait for all goroutines to finish
274+
wg.Wait()
275+
close(errorChan)
276+
277+
// Check the error channel for any errors
278+
var combinedError error
279+
for err := range errorChan {
280+
if combinedError == nil {
281+
combinedError = err
282+
} else {
283+
combinedError = fmt.Errorf("%s; %s", combinedError, err)
241284
}
242285
}
243286

287+
if combinedError != nil {
288+
return combinedError
289+
}
290+
244291
utils.LogVerbosef("Releases mirroring completed for project %s", destinationProject.HTTPURLToRepo)
245292
return nil
246293
}

0 commit comments

Comments
 (0)