Skip to content

Commit 4dba631

Browse files
authored
Merge pull request #1799 from gruntwork-io/james/oss-3398-concurrency-races
fix(opa): deduplicate concurrent policy downloads via singleflight
2 parents d982515 + ad48e08 commit 4dba631

3 files changed

Lines changed: 92 additions & 7 deletions

File tree

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ require (
9999
github.com/lib/pq v1.10.9
100100
github.com/microsoft/go-mssqldb v1.9.8
101101
github.com/slack-go/slack v0.15.0
102+
golang.org/x/sync v0.20.0
102103
google.golang.org/grpc v1.80.0
103104
gopkg.in/yaml.v3 v3.0.1
104105
gotest.tools/v3 v3.5.2
@@ -223,7 +224,6 @@ require (
223224
go.yaml.in/yaml/v2 v2.4.3 // indirect
224225
go.yaml.in/yaml/v3 v3.0.4 // indirect
225226
golang.org/x/mod v0.33.0 // indirect
226-
golang.org/x/sync v0.20.0 // indirect
227227
golang.org/x/sys v0.42.0 // indirect
228228
golang.org/x/term v0.41.0 // indirect
229229
golang.org/x/text v0.35.0 // indirect

modules/opa/download_policy.go

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"sync"
99

1010
getter "github.com/hashicorp/go-getter/v2"
11+
"golang.org/x/sync/singleflight"
1112

1213
"github.com/gruntwork-io/terratest/modules/logger"
1314
"github.com/gruntwork-io/terratest/modules/testing"
@@ -16,6 +17,10 @@ import (
1617
var (
1718
// A map that maps the go-getter base URL to the temporary directory where it is downloaded.
1819
policyDirCache sync.Map
20+
21+
// downloadGroup deduplicates concurrent downloads for the same baseDir so that N parallel callers requesting the
22+
// same rulePath result in a single underlying download rather than N separate downloads racing into N temp dirs.
23+
downloadGroup singleflight.Group
1924
)
2025

2126
// DownloadPolicyE takes in a rule path written in go-getter syntax and downloads it to a temporary directory so that it
@@ -52,13 +57,38 @@ func DownloadPolicyE(t testing.TestingT, rulePath string) (string, error) {
5257
// First, check if we had already downloaded the source and it is in our cache.
5358
baseDir, subDir := getter.SourceDirSubdir(rulePath)
5459

55-
downloadPath, hasDownloaded := policyDirCache.Load(baseDir)
56-
if hasDownloaded {
60+
if downloadPath, hasDownloaded := policyDirCache.Load(baseDir); hasDownloaded {
5761
logger.Default.Logf(t, "Previously downloaded %s: returning cached path", baseDir)
5862
return filepath.Join(downloadPath.(string), subDir), nil
5963
}
6064

61-
// Not downloaded, so use go-getter to download the remote source to a temp dir.
65+
// Cache miss. Use singleflight to ensure that only one goroutine actually performs the download for a given
66+
// baseDir; any concurrent callers block on the same call and reuse its result.
67+
v, err, _ := downloadGroup.Do(baseDir, func() (any, error) {
68+
// Re-check the cache in case another goroutine populated it while we were waiting to enter the singleflight.
69+
if downloadPath, hasDownloaded := policyDirCache.Load(baseDir); hasDownloaded {
70+
return downloadPath.(string), nil
71+
}
72+
73+
tempDir, err := downloadPolicyToTempDir(t, rulePath, baseDir)
74+
if err != nil {
75+
return "", err
76+
}
77+
78+
policyDirCache.Store(baseDir, tempDir)
79+
80+
return tempDir, nil
81+
})
82+
if err != nil {
83+
return "", err
84+
}
85+
86+
return filepath.Join(v.(string), subDir), nil
87+
}
88+
89+
// downloadPolicyToTempDir downloads the given baseDir using go-getter into a fresh temp directory and returns the path
90+
// to the directory containing the downloaded source.
91+
func downloadPolicyToTempDir(t testing.TestingT, rulePath, baseDir string) (string, error) {
6292
tempDir, err := os.MkdirTemp("", "terratest-opa-policy-*")
6393
if err != nil {
6494
return "", fmt.Errorf("creating temp directory for policy download: %w", err)
@@ -74,7 +104,5 @@ func DownloadPolicyE(t testing.TestingT, rulePath string) (string, error) {
74104
return "", fmt.Errorf("downloading policy from %s: %w", baseDir, err)
75105
}
76106

77-
policyDirCache.Store(baseDir, tempDir)
78-
79-
return filepath.Join(tempDir, subDir), nil
107+
return tempDir, nil
80108
}

modules/opa/download_policy_test.go

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"os"
55
"path/filepath"
66
"strings"
7+
"sync"
78
"testing"
89

910
"github.com/stretchr/testify/assert"
@@ -64,6 +65,62 @@ func TestDownloadPolicyDownloadsRemote(t *testing.T) {
6465
assert.Equal(t, localContents, remoteContents)
6566
}
6667

68+
// TestDownloadPolicyDeduplicatesConcurrentDownloads makes sure concurrent calls for the same rulePath collapse to a
69+
// single cache entry rather than racing into separate temp directories.
70+
//
71+
//nolint:paralleltest // go-getter's Client.configure has an internal race on its global Getters map, so we cannot run alongside other go-getter tests.
72+
func TestDownloadPolicyDeduplicatesConcurrentDownloads(t *testing.T) {
73+
baseDir := "git::https://github.com/gruntwork-io/terratest.git?ref=v0.50.0"
74+
remotePath := "git::https://github.com/gruntwork-io/terratest.git//examples/terraform-opa-example/policy/enforce_source.rego?ref=v0.50.0"
75+
76+
defer func() {
77+
if cached, ok := opa.PolicyDirCache.Load(baseDir); ok {
78+
downloadPath := cached.(string)
79+
if strings.HasSuffix(downloadPath, "/getter") {
80+
downloadPath = filepath.Dir(downloadPath)
81+
}
82+
83+
os.RemoveAll(downloadPath)
84+
}
85+
}()
86+
87+
tempDirGlob := filepath.Join(os.TempDir(), "terratest-opa-policy-*")
88+
before, _ := filepath.Glob(tempDirGlob)
89+
90+
const numGoroutines = 5
91+
92+
var wg sync.WaitGroup
93+
94+
results := make([]string, numGoroutines)
95+
errs := make([]error, numGoroutines)
96+
97+
for i := 0; i < numGoroutines; i++ {
98+
wg.Add(1)
99+
100+
go func(idx int) {
101+
defer wg.Done()
102+
103+
path, err := opa.DownloadPolicyE(t, remotePath)
104+
105+
errs[idx] = err
106+
results[idx] = path
107+
}(i)
108+
}
109+
110+
wg.Wait()
111+
112+
for i := 0; i < numGoroutines; i++ {
113+
require.NoError(t, errs[i])
114+
}
115+
116+
for i := 1; i < numGoroutines; i++ {
117+
assert.Equal(t, results[0], results[i])
118+
}
119+
120+
after, _ := filepath.Glob(tempDirGlob)
121+
assert.Len(t, after, len(before)+1, "expected exactly one new temp dir; dedup may have failed")
122+
}
123+
67124
// TestDownloadPolicyReusesCachedDir makes sure the DownloadPolicyE function uses the cache if it has already downloaded
68125
// an existing base path.
69126
func TestDownloadPolicyReusesCachedDir(t *testing.T) {

0 commit comments

Comments
 (0)