Repository: argoproj/argo-cd
Pull request #4667 (2020-10-26 20:27:44)

chore: Change log level in updateClusters() from info to debug
There's a log message with level info in the application controller that gets printed each 10 seconds. IMO it does not add any value at all for day-to-day operations of argo-cd, and therefore I think we should decrease loglevel to debug for this particular message.

Log message is
```
20:16:59  controller | INFO[0800] Successfully saved info of 1 clusters
```

Checklist:

* [ ] Either (a) I've created an [enhancement proposal](https://github.com/argoproj/argo-cd/issues/new/choose) and discussed it with the community, (b) this is a bug fix, or (c) this does not need to be in the release notes.
* [ ] The title of the PR states what changed and the related issues number (used for the release note).
* [ ] I've updated both the CLI and UI to expose my feature, or I plan to submit a second PR with them.
* [ ] Does this PR require documentation updates?
* [ ] I've updated documentation as required by this PR.
* [ ] Optional. My organization is added to USERS.md.
* [ ] I've signed the CLA and my build is green ([troubleshooting builds](https://argoproj.github.io/argo-cd/developer-guide/ci/)). 



Patch:
diff --git a/controller/clusterinfoupdater.go b/controller/clusterinfoupdater.go
index 80ca537fe..0fa0c277f 100644
--- a/controller/clusterinfoupdater.go
+++ b/controller/clusterinfoupdater.go
@@ -82,7 +82,7 @@ func (c *clusterInfoUpdater) updateClusters() {
 		}
 		return nil
 	})
-	log.Infof("Successfully saved info of %d clusters", len(clustersFiltered))
+	log.Debugf("Successfully saved info of %d clusters", len(clustersFiltered))
 }
 
 func (c *clusterInfoUpdater) updateClusterInfo(cluster appv1.Cluster, info *cache.ClusterInfo) error {
diff --git a/docs/getting_started.md b/docs/getting_started.md
index 22da92562..3c4a7fe8c 100644
--- a/docs/getting_started.md
+++ b/docs/getting_started.md
@@ -17,12 +17,6 @@ kubectl apply -n argocd -f https://raw.githubusercontent.com/argoproj/argo-cd/st
 
 This will create a new namespace, `argocd`, where Argo CD services and application resources will live.
 
-On GKE, you will need grant your account the ability to create new cluster roles:
-    
-```bash
-kubectl create clusterrolebinding cluster-admin-binding --clusterrole=cluster-admin --user="$(gcloud config get-value account)"
-```
-
 !!! note
     If you are not interested in UI, SSO, multi-cluster management and just want to pull changes into the cluster then you can disable
     authentication using `--disable-auth` flag and access Argo CD via CLI using `--port-forward` or `--port-forward-namespace` flags
diff --git a/docs/operator-manual/high_availability.md b/docs/operator-manual/high_availability.md
index 149a17c07..de9f371af 100644
--- a/docs/operator-manual/high_availability.md
+++ b/docs/operator-manual/high_availability.md
@@ -20,8 +20,16 @@ The `argocd-repo-server` is responsible for cloning Git repository, keeping it u
 * `argocd-repo-server` fork/exec config management tool to generate manifests. The fork can fail due to lack of memory and limit on the number of OS threads.
 The `--parallelismlimit` flag controls how many manifests generations are running concurrently and allows avoiding OOM kills.
 
-* one instance of `argocd-repo-server` executes only one operation on one Git repo concurrently. Increase the number of `argocd-repo-server` replica count if you have a lot of
-applications in the same repository.
+* the `argocd-repo-server` ensures that repository is in the clean state during the manifest generation using config management tools such as Kustomize, Helm
+or custom plugin. If the manifest generation requires to change file in the local repository clone then only one concurrent manifest generation per server
+instance is allowed that might slowdown Argo CD if you have multiple applications (50+) in the same repository. Following are known cases that might cause slowness and workarounds:
+
+  * Multiple Helm based applications pointing to the same directory in one Git repository: ensure that your Helm chart don't have don't have conditional
+[dependencies](https://helm.sh/docs/chart_best_practices/dependencies/#conditions-and-tags) and create `.argocd-allow-concurrency` file in chart directory.
+
+  * Multiple Custom plugin based applications: avoid creating temporal files during manifest generation and and create `.argocd-allow-concurrency` file in app directory.
+
+  * Multiple Kustomize or Ksonnet applications in same repository with [parameter overrides](../user-guide/parameters.md): sorry, no workaround for now.
 
 * `argocd-repo-server` clones repository into `/tmp` ( of path specified in `TMPDIR` env variable ). Pod might run out of disk space if have too many repository
 or repositories has a lot of files. To avoid this problem mount persistent volume.
diff --git a/pkg/apis/application/v1alpha1/types.go b/pkg/apis/application/v1alpha1/types.go
index 72c9f352a..99ac23fc1 100644
--- a/pkg/apis/application/v1alpha1/types.go
+++ b/pkg/apis/application/v1alpha1/types.go
@@ -149,6 +149,19 @@ type ApplicationSource struct {
 	Chart string `json:"chart,omitempty" protobuf:"bytes,12,opt,name=chart"`
 }
 
+// AllowsConcurrentProcessing returns true if given application source can be processed concurrently
+func (a *ApplicationSource) AllowsConcurrentProcessing() bool {
+	switch {
+	// Kustomize with parameters requires changing kustomization.yaml file
+	case a.Kustomize != nil:
+		return a.Kustomize.AllowsConcurrentProcessing()
+	// Kustomize with parameters requires changing params.libsonnet file
+	case a.Ksonnet != nil:
+		return a.Ksonnet.AllowsConcurrentProcessing()
+	}
+	return true
+}
+
 func (a *ApplicationSource) IsHelm() bool {
 	return a.Chart != ""
 }
@@ -319,6 +332,13 @@ type ApplicationSourceKustomize struct {
 	Version string `json:"version,omitempty" protobuf:"bytes,5,opt,name=version"`
 }
 
+func (k *ApplicationSourceKustomize) AllowsConcurrentProcessing() bool {
+	return len(k.Images) == 0 &&
+		len(k.CommonLabels) == 0 &&
+		k.NamePrefix == "" &&
+		k.NameSuffix == ""
+}
+
 func (k *ApplicationSourceKustomize) IsZero() bool {
 	return k == nil ||
 		k.NamePrefix == "" &&
@@ -383,6 +403,10 @@ type KsonnetParameter struct {
 	Value     string `json:"value" protobuf:"bytes,3,opt,name=value"`
 }
 
+func (k *ApplicationSourceKsonnet) AllowsConcurrentProcessing() bool {
+	return len(k.Parameters) == 0
+}
+
 func (k *ApplicationSourceKsonnet) IsZero() bool {
 	return k == nil || k.Environment == "" && len(k.Parameters) == 0
 }
diff --git a/reposerver/repository/lock.go b/reposerver/repository/lock.go
new file mode 100644
index 000000000..385ac4a1d
--- /dev/null
+++ b/reposerver/repository/lock.go
@@ -0,0 +1,76 @@
+package repository
+
+import (
+	"io"
+	"sync"
+
+	ioutil "github.com/argoproj/gitops-engine/pkg/utils/io"
+)
+
+func NewRepositoryLock() *repositoryLock {
+	return &repositoryLock{stateByKey: map[string]*repositoryState{}}
+}
+
+type repositoryLock struct {
+	lock       sync.Mutex
+	stateByKey map[string]*repositoryState
+}
+
+// Lock acquires lock unless lock is already acquired with the same commit and allowConcurrent is set to true
+func (r *repositoryLock) Lock(path string, revision string, allowConcurrent bool, init func() error) (io.Closer, error) {
+	r.lock.Lock()
+	state, ok := r.stateByKey[path]
+	if !ok {
+		state = &repositoryState{cond: &sync.Cond{L: &sync.Mutex{}}}
+		r.stateByKey[path] = state
+	}
+	r.lock.Unlock()
+
+	closer := ioutil.NewCloser(func() error {
+		state.cond.L.Lock()
+		notify := false
+		state.processCount--
+		if state.processCount == 0 {
+			notify = true
+			state.revision = ""
+		}
+		state.cond.L.Unlock()
+		if notify {
+			state.cond.Broadcast()
+		}
+		return nil
+	})
+
+	for {
+		state.cond.L.Lock()
+		if state.revision == "" {
+			// no in progress operation for that repo. Go ahead.
+			if err := init(); err != nil {
+				state.cond.L.Unlock()
+				return nil, err
+			}
+
+			state.revision = revision
+			state.processCount = 1
+			state.allowConcurrent = allowConcurrent
+			state.cond.L.Unlock()
+			return closer, nil
+		} else if state.revision == revision && state.allowConcurrent && allowConcurrent {
+			// same revision already processing and concurrent processing allowed. Increment process count and go ahead.
+			state.processCount++
+			state.cond.L.Unlock()
+			return closer, nil
+		} else {
+			state.cond.Wait()
+			// wait when all in-flight processes of this revision complete and try again
+			state.cond.L.Unlock()
+		}
+	}
+}
+
+type repositoryState struct {
+	cond            *sync.Cond
+	revision        string
+	processCount    int
+	allowConcurrent bool
+}
diff --git a/reposerver/repository/repository.go b/reposerver/repository/repository.go
index 6a58b632e..661753288 100644
--- a/reposerver/repository/repository.go
+++ b/reposerver/repository/repository.go
@@ -53,11 +53,13 @@ import (
 
 const (
 	cachedManifestGenerationPrefix = "Manifest generation error (cached)"
+	helmDepUpMarkerFile            = ".argocd-helm-dep-up"
+	allowConcurrencyFile           = ".argocd-allow-concurrency"
 )
 
 // Service implements ManifestService interface
 type Service struct {
-	repoLock                  sync.KeyLock
+	repoLock                  *repositoryLock
 	cache                     *reposervercache.Cache
 	parallelismLimitSemaphore *semaphore.Weighted
 	metricsServer             *metrics.MetricsServer
@@ -81,7 +83,7 @@ func NewService(metricsServer *metrics.MetricsServer, cache *reposervercache.Cac
 	if initConstants.ParallelismLimit > 0 {
 		parallelismLimitSemaphore = semaphore.NewWeighted(initConstants.ParallelismLimit)
 	}
-	repoLock := sync.NewKeyLock()
+	repoLock := NewRepositoryLock()
 	return &Service{
 		parallelismLimitSemaphore: parallelismLimitSemaphore,
 		repoLock:                  repoLock,
@@ -89,7 +91,7 @@ func NewService(metricsServer *metrics.MetricsServer, cache *reposervercache.Cac
 		metricsServer:             metricsServer,
 		newGitClient:              git.NewClient,
 		newHelmClient: func(repoURL string, creds helm.Creds) helm.Client {
-			return helm.NewClientWithLock(repoURL, creds, repoLock)
+			return helm.NewClientWithLock(repoURL, creds, sync.NewKeyLock())
 		},
 		initConstants: initConstants,
 		now:           time.Now,
@@ -110,13 +112,15 @@ func (s *Service) ListApps(ctx context.Context, q *apiclient.ListAppsRequest) (*
 	s.metricsServer.IncPendingRepoRequest(q.Repo.Repo)
 	defer s.metricsServer.DecPendingRepoRequest(q.Repo.Repo)
 
-	s.repoLock.Lock(gitClient.Root())
-	defer s.repoLock.Unlock(gitClient.Root())
+	closer, err := s.repoLock.Lock(gitClient.Root(), commitSHA, true, func() error {
+		return checkoutRevision(gitClient, commitSHA)
+	})
 
-	_, err = checkoutRevision(gitClient, commitSHA, log.WithField("repo", q.Repo.Repo))
 	if err != nil {
 		return nil, err
 	}
+
+	defer io.Close(closer)
 	apps, err := discovery.Discover(gitClient.Root())
 	if err != nil {
 		return nil, err
@@ -130,8 +134,9 @@ func (s *Service) ListApps(ctx context.Context, q *apiclient.ListAppsRequest) (*
 }
 
 type operationSettings struct {
-	sem     *semaphore.Weighted
-	noCache bool
+	sem             *semaphore.Weighted
+	noCache         bool
+	allowConcurrent bool
 }
 
 // runRepoOperation downloads either git folder or helm chart and executes specified operation
@@ -201,8 +206,21 @@ func (s *Service) runRepoOperation(
 		defer io.Close(closer)
 		return operation(chartPath, chartPath, revision, revision, "")
 	} else {
-		s.repoLock.Lock(gitClient.Root())
-		defer s.repoLock.Unlock(gitClient.Root())
+		closer, err := s.repoLock.Lock(gitClient.Root(), revision, settings.allowConcurrent, func() error {
+			return checkoutRevision(gitClient, revision)
+		})
+
+		if err != nil {
+			return err, nil
+		}
+
+		defer io.Close(closer)
+
+		commitSHA, err := gitClient.CommitSHA()
+		if err != nil {
+			return err, nil
+		}
+
 		// double-check locking
 		if !settings.noCache {
 			result, obj, err := getCached(revision, false)
@@ -210,10 +228,6 @@ func (s *Service) runRepoOperation(
 				return obj, err
 			}
 		}
-		commitSHA, err := checkoutRevision(gitClient, revision, log.WithField("repo", repo.Repo))
-		if err != nil {
-			return nil, err
-		}
 		if verifyCommit {
 			signature, err = gitClient.VerifyCommitSignature(revision)
 			if err != nil {
@@ -236,8 +250,7 @@ func (s *Service) GenerateManifest(ctx context.Context, q *apiclient.ManifestReq
 			return s.getManifestCacheEntry(cacheKey, q, firstInvocation)
 		}, func(appPath, repoRoot, commitSHA, cacheKey, verifyResult string) (interface{}, error) {
 			return s.runManifestGen(appPath, repoRoot, commitSHA, cacheKey, verifyResult, q)
-		}, operationSettings{sem: s.parallelismLimitSemaphore, noCache: q.NoCache})
-
+		}, operationSettings{sem: s.parallelismLimitSemaphore, noCache: q.NoCache, allowConcurrent: q.ApplicationSource.AllowsConcurrentProcessing()})
 	result, ok := resultUncast.(*apiclient.ManifestResponse)
 	if result != nil && !ok {
 		return nil, errors.New("unexpected result type")
@@ -398,7 +411,48 @@ func getHelmRepos(repositories []*v1alpha1.Repository) []helm.HelmRepository {
 	return repos
 }
 
+func isConcurrencyAllowed(appPath string) bool {
+	if _, err := os.Stat(path.Join(appPath, allowConcurrencyFile)); err == nil {
+		return true
+	}
+	return false
+}
+
+var manifestGenerateLock = sync.NewKeyLock()
+
+// runHelmBuild executes `helm dependency build` in a given path and ensures that it is executed only once
+// if multiple threads are trying to run it.
+// Multiple goroutines might process same helm app in one repo concurrently when repo server process multiple
+// manifest generation requests of the same commit.
+func runHelmBuild(appPath string, h helm.Helm) error {
+	manifestGenerateLock.Lock(appPath)
+	defer manifestGenerateLock.Unlock(appPath)
+
+	// the `helm dependency build` is potentially time consuming 1~2 seconds
+	// marker file is used to check if command already run to avoid running it again unnecessary
+	// file is removed when repository re-initialized (e.g. when another commit is processed)
+	markerFile := path.Join(appPath, helmDepUpMarkerFile)
+	_, err := os.Stat(markerFile)
+	if err == nil {
+		return nil
+	} else if !os.IsNotExist(err) {
+		return err
+	}
+
+	err = h.DependencyBuild()
+	if err != nil {
+		return err
+	}
+	return ioutil.WriteFile(markerFile, []byte("marker"), 0644)
+}
+
 func helmTemplate(appPath string, repoRoot string, env *v1alpha1.Env, q *apiclient.ManifestRequest, isLocal bool) ([]*unstructured.Unstructured, error) {
+	concurrencyAllowed := isConcurrencyAllowed(appPath)
+	if !concurrencyAllowed {
+		manifestGenerateLock.Lock(appPath)
+		defer manifestGenerateLock.Unlock(appPath)
+	}
+
 	templateOpts := &helm.TemplateOpts{
 		Name:        q.AppLabelValue,
 		Namespace:   q.Namespace,
@@ -494,15 +548,23 @@ func helmTemplate(appPath string, repoRoot string, env *v1alpha1.Env, q *apiclie
 	if err != nil {
 		return nil, err
 	}
+
 	out, err := h.Template(templateOpts)
 	if err != nil {
 		if !helm.IsMissingDependencyErr(err) {
 			return nil, err
 		}
-		err = h.DependencyBuild()
+
+		if concurrencyAllowed {
+			err = runHelmBuild(appPath, h)
+		} else {
+			err = h.DependencyBuild()
+		}
+
 		if err != nil {
 			return nil, err
 		}
+
 		out, err = h.Template(templateOpts)
 		if err != nil {
 			return nil, err
@@ -854,6 +916,12 @@ func findPlugin(plugins []*v1alpha1.ConfigManagementPlugin, name string) *v1alph
 }
 
 func runConfigManagementPlugin(appPath string, envVars *v1alpha1.Env, q *apiclient.ManifestRequest, creds git.Creds) ([]*unstructured.Unstructured, error) {
+	concurrencyAllowed := isConcurrencyAllowed(appPath)
+	if !concurrencyAllowed {
+		manifestGenerateLock.Lock(appPath)
+		defer manifestGenerateLock.Unlock(appPath)
+	}
+
 	plugin := findPlugin(q.Plugins, q.ApplicationSource.Plugin.Name)
 	if plugin == nil {
 		return nil, fmt.Errorf("Config management plugin with name '%s' is not supported.", q.ApplicationSource.Plugin.Name)
@@ -1007,7 +1075,7 @@ func (s *Service) GetAppDetails(ctx context.Context, q *apiclient.RepoServerAppD
 		}
 		_ = s.cache.SetAppDetails(revision, q.Source, res)
 		return res, nil
-	}, operationSettings{})
+	}, operationSettings{allowConcurrent: q.Source.AllowsConcurrentProcessing()})
 
 	result, ok := resultUncast.(*apiclient.RepoAppDetailsResponse)
 	if result != nil && !ok {
@@ -1041,14 +1109,16 @@ func (s *Service) GetRevisionMetadata(ctx context.Context, q *apiclient.RepoServ
 	s.metricsServer.IncPendingRepoRequest(q.Repo.Repo)
 	defer s.metricsServer.DecPendingRepoRequest(q.Repo.Repo)
 
-	s.repoLock.Lock(gitClient.Root())
-	defer s.repoLock.Unlock(gitClient.Root())
+	closer, err := s.repoLock.Lock(gitClient.Root(), q.Revision, true, func() error {
+		return checkoutRevision(gitClient, q.Revision)
+	})
 
-	_, err = checkoutRevision(gitClient, q.Revision, log.WithField("repo", q.Repo.Repo))
 	if err != nil {
 		return nil, err
 	}
 
+	defer io.Close(closer)
+
 	m, err := gitClient.RevisionMetadata(q.Revision)
 	if err != nil {
 		return nil, err
@@ -1145,24 +1215,20 @@ func (s *Service) newHelmClientResolveRevision(repo *v1alpha1.Repository, revisi
 // checkoutRevision is a convenience function to initialize a repo, fetch, and checkout a revision
 // Returns the 40 character commit SHA after the checkout has been performed
 // nolint:unparam
-func checkoutRevision(gitClient git.Client, commitSHA string, logEntry *log.Entry) (string, error) {
+func checkoutRevision(gitClient git.Client, revision string) error {
 	err := gitClient.Init()
 	if err != nil {
-		return "", status.Errorf(codes.Internal, "Failed to initialize git repo: %v", err)
+		return status.Errorf(codes.Internal, "Failed to initialize git repo: %v", err)
 	}
 	err = gitClient.Fetch()
 	if err != nil {
-		return "", status.Errorf(codes.Internal, "Failed to fetch git repo: %v", err)
+		return status.Errorf(codes.Internal, "Failed to fetch git repo: %v", err)
 	}
-	err = gitClient.Checkout(commitSHA)
+	err = gitClient.Checkout(revision)
 	if err != nil {
-		return "", status.Errorf(codes.Internal, "Failed to checkout %s: %v", commitSHA, err)
-	}
-	sha, err := gitClient.CommitSHA()
-	if err == nil && git.IsCommitSHA(commitSHA) && sha != commitSHA {
-		logEntry.Warnf("'git checkout %s' has switched repo to unexpected commit: %s", commitSHA, sha)
+		return status.Errorf(codes.Internal, "Failed to checkout %s: %v", revision, err)
 	}
-	return sha, err
+	return err
 }
 
 func (s *Service) GetHelmCharts(ctx context.Context, q *apiclient.HelmChartsRequest) (*apiclient.HelmChartsResponse, error) {


Test patch:
diff --git a/pkg/apis/application/v1alpha1/types_test.go b/pkg/apis/application/v1alpha1/types_test.go
index ff976ce07..e32af90e9 100644
--- a/pkg/apis/application/v1alpha1/types_test.go
+++ b/pkg/apis/application/v1alpha1/types_test.go
@@ -2099,3 +2099,27 @@ func TestRetryStrategy_NextRetryAtCustomBackoff(t *testing.T) {
 		assert.Equal(t, expected.Format(time.RFC850), retryAt.Format(time.RFC850))
 	}
 }
+
+func TestSourceAllowsConcurrentProcessing_KsonnetNoParams(t *testing.T) {
+	src := ApplicationSource{Path: "."}
+
+	assert.True(t, src.AllowsConcurrentProcessing())
+}
+
+func TestSourceAllowsConcurrentProcessing_KsonnetParams(t *testing.T) {
+	src := ApplicationSource{Path: ".", Ksonnet: &ApplicationSourceKsonnet{
+		Parameters: []KsonnetParameter{{
+			Name: "test", Component: "test", Value: "1",
+		}},
+	}}
+
+	assert.False(t, src.AllowsConcurrentProcessing())
+}
+
+func TestSourceAllowsConcurrentProcessing_KustomizeParams(t *testing.T) {
+	src := ApplicationSource{Path: ".", Kustomize: &ApplicationSourceKustomize{
+		NameSuffix: "test",
+	}}
+
+	assert.False(t, src.AllowsConcurrentProcessing())
+}
diff --git a/reposerver/repository/lock_test.go b/reposerver/repository/lock_test.go
new file mode 100644
index 000000000..a3ec5d274
--- /dev/null
+++ b/reposerver/repository/lock_test.go
@@ -0,0 +1,170 @@
+package repository
+
+import (
+	"errors"
+	"io"
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/assert"
+
+	util "github.com/argoproj/gitops-engine/pkg/utils/io"
+)
+
+// execute given action and return false if action have not completed within 1 second
+func lockQuickly(action func() (io.Closer, error)) (io.Closer, bool) {
+	done := make(chan io.Closer)
+	go func() {
+		closer, _ := action()
+		done <- closer
+	}()
+	select {
+	case <-time.After(1 * time.Second):
+		return nil, false
+	case closer := <-done:
+		return closer, true
+	}
+}
+
+func numberOfInits(initializedTimes *int) func() error {
+	return func() error {
+		*initializedTimes++
+		return nil
+	}
+}
+
+func TestLock_SameRevision(t *testing.T) {
+	lock := NewRepositoryLock()
+	initializedTimes := 0
+	init := numberOfInits(&initializedTimes)
+	closer1, done := lockQuickly(func() (io.Closer, error) {
+		return lock.Lock("myRepo", "1", true, init)
+	})
+
+	if !assert.True(t, done) {
+		return
+	}
+
+	closer2, done := lockQuickly(func() (io.Closer, error) {
+		return lock.Lock("myRepo", "1", true, init)
+	})
+
+	if !assert.True(t, done) {
+		return
+	}
+
+	assert.Equal(t, 1, initializedTimes)
+
+	util.Close(closer1)
+
+	util.Close(closer2)
+}
+
+func TestLock_DifferentRevisions(t *testing.T) {
+	lock := NewRepositoryLock()
+	initializedTimes := 0
+	init := numberOfInits(&initializedTimes)
+
+	closer1, done := lockQuickly(func() (io.Closer, error) {
+		return lock.Lock("myRepo", "1", true, init)
+	})
+
+	if !assert.True(t, done) {
+		return
+	}
+
+	_, done = lockQuickly(func() (io.Closer, error) {
+		return lock.Lock("myRepo", "2", true, init)
+	})
+
+	if !assert.False(t, done) {
+		return
+	}
+
+	util.Close(closer1)
+
+	_, done = lockQuickly(func() (io.Closer, error) {
+		return lock.Lock("myRepo", "2", true, init)
+	})
+
+	if !assert.True(t, done) {
+		return
+	}
+}
+
+func TestLock_NoConcurrentWithSameRevision(t *testing.T) {
+	lock := NewRepositoryLock()
+	initializedTimes := 0
+	init := numberOfInits(&initializedTimes)
+
+	closer1, done := lockQuickly(func() (io.Closer, error) {
+		return lock.Lock("myRepo", "1", false, init)
+	})
+
+	if !assert.True(t, done) {
+		return
+	}
+
+	_, done = lockQuickly(func() (io.Closer, error) {
+		return lock.Lock("myRepo", "1", false, init)
+	})
+
+	if !assert.False(t, done) {
+		return
+	}
+
+	util.Close(closer1)
+}
+
+func TestLock_FailedInitialization(t *testing.T) {
+	lock := NewRepositoryLock()
+
+	closer1, done := lockQuickly(func() (io.Closer, error) {
+		return lock.Lock("myRepo", "1", true, func() error {
+			return errors.New("failed")
+		})
+	})
+
+	if !assert.True(t, done) {
+		return
+	}
+
+	assert.Nil(t, closer1)
+
+	closer2, done := lockQuickly(func() (io.Closer, error) {
+		return lock.Lock("myRepo", "1", true, func() error {
+			return nil
+		})
+	})
+
+	if !assert.True(t, done) {
+		return
+	}
+
+	util.Close(closer2)
+}
+
+func TestLock_SameRevisionFirstNotConcurrent(t *testing.T) {
+	lock := NewRepositoryLock()
+	initializedTimes := 0
+	init := numberOfInits(&initializedTimes)
+	closer1, done := lockQuickly(func() (io.Closer, error) {
+		return lock.Lock("myRepo", "1", false, init)
+	})
+
+	if !assert.True(t, done) {
+		return
+	}
+
+	_, done = lockQuickly(func() (io.Closer, error) {
+		return lock.Lock("myRepo", "1", true, init)
+	})
+
+	if !assert.False(t, done) {
+		return
+	}
+
+	assert.Equal(t, 1, initializedTimes)
+
+	util.Close(closer1)
+}
diff --git a/reposerver/repository/repository_test.go b/reposerver/repository/repository_test.go
index d09043091..86827ac52 100644
--- a/reposerver/repository/repository_test.go
+++ b/reposerver/repository/repository_test.go
@@ -10,6 +10,7 @@ import (
 	"path/filepath"
 	"regexp"
 	"strings"
+	"sync"
 	"testing"
 	"time"
 
@@ -1174,3 +1175,27 @@ func TestGenerateManifestWithAnnotatedAndRegularGitTagHashes(t *testing.T) {
 	}
 
 }
+
+func TestHelmDependencyWithConcurrency(t *testing.T) {
+	cleanup := func() {
+		_ = os.Remove(filepath.Join("../../util/helm/testdata/helm2-dependency", helmDepUpMarkerFile))
+		_ = os.RemoveAll(filepath.Join("../../util/helm/testdata/helm2-dependency", "charts"))
+	}
+	cleanup()
+	defer cleanup()
+
+	var wg sync.WaitGroup
+	wg.Add(3)
+	for i := 0; i < 3; i++ {
+		go func() {
+			res, err := helmTemplate("../../util/helm/testdata/helm2-dependency", "../..", nil, &apiclient.ManifestRequest{
+				ApplicationSource: &argoappv1.ApplicationSource{},
+			}, false)
+
+			assert.NoError(t, err)
+			assert.NotNil(t, res)
+			wg.Done()
+		}()
+	}
+	wg.Wait()
+}