Skip to content

Commit 1cb608f

Browse files
authored
chore: Update cfg locking for units (#6401)
* chore: Updating locking logic for units to mitigate a race * chore: Adding `GuardConfigParse` * chore: Cleaning up some commentary
1 parent 01ebbae commit 1cb608f

6 files changed

Lines changed: 176 additions & 37 deletions

File tree

internal/component/component.go

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,6 @@ type Component interface {
4343

4444
lock()
4545
unlock()
46-
rLock()
47-
rUnlock()
4846

4947
ensureDependency(Component)
5048
ensureDependent(Component)

internal/component/component_test.go

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@ package component_test
22

33
import (
44
"sync"
5+
"sync/atomic"
56
"testing"
67

78
"github.com/gruntwork-io/terragrunt/internal/component"
9+
"github.com/gruntwork-io/terragrunt/pkg/config"
810
"github.com/stretchr/testify/assert"
911
"github.com/stretchr/testify/require"
1012
)
@@ -235,3 +237,34 @@ func TestThreadSafeComponentsConcurrentAccess(t *testing.T) {
235237
// Should have exactly one component despite concurrent adds
236238
assert.Equal(t, 1, tsc.Len(), "should have exactly one component after concurrent adds")
237239
}
240+
241+
// TestUnitGuardConfigParseWithRacing verifies the parse runs exactly once when
242+
// many goroutines race to parse the same unit.
243+
func TestUnitGuardConfigParseWithRacing(t *testing.T) {
244+
t.Parallel()
245+
246+
unit := component.NewUnit("/test/unit")
247+
248+
var parses atomic.Int32
249+
250+
var wg sync.WaitGroup
251+
252+
const goroutines = 32
253+
254+
for range goroutines {
255+
wg.Go(func() {
256+
err := unit.GuardConfigParse(func() error {
257+
parses.Add(1)
258+
unit.StoreConfig(&config.TerragruntConfig{})
259+
260+
return nil
261+
})
262+
assert.NoError(t, err)
263+
})
264+
}
265+
266+
wg.Wait()
267+
268+
assert.Equal(t, int32(1), parses.Load(), "config should be parsed exactly once")
269+
assert.NotNil(t, unit.Config(), "config should be populated after parsing")
270+
}

internal/component/stack.go

Lines changed: 17 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ type Stack struct {
2424
dependencies Components
2525
dependents Components
2626
Units []*Unit
27-
mu sync.RWMutex
27+
mu sync.Mutex
2828
external bool
2929
}
3030

@@ -47,11 +47,17 @@ func (s *Stack) WithDiscoveryContext(ctx *DiscoveryContext) *Stack {
4747

4848
// Config returns the parsed Stack configuration for this stack.
4949
func (s *Stack) Config() *config.StackConfig {
50+
s.lock()
51+
defer s.unlock()
52+
5053
return s.cfg
5154
}
5255

5356
// StoreConfig stores the parsed Stack configuration for this stack.
5457
func (s *Stack) StoreConfig(cfg *config.StackConfig) {
58+
s.lock()
59+
defer s.unlock()
60+
5561
s.cfg = cfg
5662
}
5763

@@ -96,11 +102,17 @@ func (s *Stack) SetExternal() {
96102

97103
// Reading returns the list of files being read by this component.
98104
func (s *Stack) Reading() []string {
105+
s.lock()
106+
defer s.unlock()
107+
99108
return s.reading
100109
}
101110

102111
// SetReading sets the list of files being read by this component.
103112
func (s *Stack) SetReading(files ...string) {
113+
s.lock()
114+
defer s.unlock()
115+
104116
s.reading = files
105117
}
106118

@@ -145,16 +157,6 @@ func (s *Stack) unlock() {
145157
s.mu.Unlock()
146158
}
147159

148-
// rLock locks the Stack for reading.
149-
func (s *Stack) rLock() {
150-
s.mu.RLock()
151-
}
152-
153-
// rUnlock unlocks the Stack for reading.
154-
func (s *Stack) rUnlock() {
155-
s.mu.RUnlock()
156-
}
157-
158160
// AddDependency adds a dependency to the Stack and vice versa.
159161
//
160162
// Using this method ensure that the dependency graph is properly maintained,
@@ -199,16 +201,16 @@ func (s *Stack) AddDependent(dependent Component) {
199201

200202
// Dependencies returns the dependencies of the Stack.
201203
func (s *Stack) Dependencies() Components {
202-
s.rLock()
203-
defer s.rUnlock()
204+
s.lock()
205+
defer s.unlock()
204206

205207
return s.dependencies
206208
}
207209

208210
// Dependents returns the dependents of the Stack.
209211
func (s *Stack) Dependents() Components {
210-
s.rLock()
211-
defer s.rUnlock()
212+
s.lock()
213+
defer s.unlock()
212214

213215
return s.dependents
214216
}

internal/component/unit.go

Lines changed: 42 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,8 @@ type Unit struct {
2525
reading []string
2626
dependencies Components
2727
dependents Components
28-
mu sync.RWMutex
28+
mu sync.Mutex
29+
parseMu sync.Mutex
2930
external bool
3031
excluded bool
3132
}
@@ -51,7 +52,7 @@ func (u *Unit) WithReading(files ...string) *Unit {
5152

5253
// WithConfig adds configuration to a Unit component.
5354
func (u *Unit) WithConfig(cfg *config.TerragruntConfig) *Unit {
54-
u.cfg = cfg
55+
u.StoreConfig(cfg)
5556

5657
return u
5758
}
@@ -65,14 +66,37 @@ func (u *Unit) WithDiscoveryContext(ctx *DiscoveryContext) *Unit {
6566

6667
// Config returns the parsed Terragrunt configuration for this unit.
6768
func (u *Unit) Config() *config.TerragruntConfig {
69+
u.lock()
70+
defer u.unlock()
71+
6872
return u.cfg
6973
}
7074

7175
// StoreConfig stores the parsed Terragrunt configuration for this unit.
7276
func (u *Unit) StoreConfig(cfg *config.TerragruntConfig) {
77+
u.lock()
78+
defer u.unlock()
79+
7380
u.cfg = cfg
7481
}
7582

83+
// GuardConfigParse runs parse to populate this unit's config at most once, even
84+
// when called concurrently. Callers that lose the race skip parse and observe
85+
// the config stored by the winner.
86+
//
87+
// The guard is separate from the config mutex so a slow parse doesn't block
88+
// reads of an already-parsed unit.
89+
func (u *Unit) GuardConfigParse(parse func() error) error {
90+
u.parseMu.Lock()
91+
defer u.parseMu.Unlock()
92+
93+
if u.Config() != nil {
94+
return nil
95+
}
96+
97+
return parse()
98+
}
99+
76100
// ConfigFile returns the discovered config filename for this unit.
77101
func (u *Unit) ConfigFile() string {
78102
return u.configFile
@@ -120,16 +144,25 @@ func (u *Unit) SetExcluded(excluded bool) {
120144

121145
// Reading returns the list of files being read by this component.
122146
func (u *Unit) Reading() []string {
147+
u.lock()
148+
defer u.unlock()
149+
123150
return u.reading
124151
}
125152

126153
// SetReading sets the list of files being read by this component.
127154
func (u *Unit) SetReading(files ...string) {
155+
u.lock()
156+
defer u.unlock()
157+
128158
u.reading = files
129159
}
130160

131161
// Sources returns the list of sources for this component.
132162
func (u *Unit) Sources() []string {
163+
u.lock()
164+
defer u.unlock()
165+
133166
if u.cfg == nil || u.cfg.Terraform == nil || u.cfg.Terraform.Source == nil {
134167
return []string{}
135168
}
@@ -166,16 +199,6 @@ func (u *Unit) unlock() {
166199
u.mu.Unlock()
167200
}
168201

169-
// rLock locks the Unit for reading.
170-
func (u *Unit) rLock() {
171-
u.mu.RLock()
172-
}
173-
174-
// rUnlock unlocks the Unit for reading.
175-
func (u *Unit) rUnlock() {
176-
u.mu.RUnlock()
177-
}
178-
179202
// AddDependency adds a dependency to the Unit and vice versa.
180203
//
181204
// Using this method ensure that the dependency graph is properly maintained,
@@ -220,16 +243,16 @@ func (u *Unit) AddDependent(dependent Component) {
220243

221244
// Dependencies returns the dependencies of the Unit.
222245
func (u *Unit) Dependencies() Components {
223-
u.rLock()
224-
defer u.rUnlock()
246+
u.lock()
247+
defer u.unlock()
225248

226249
return u.dependencies
227250
}
228251

229252
// Dependents returns the dependents of the Unit.
230253
func (u *Unit) Dependents() Components {
231-
u.rLock()
232-
defer u.rUnlock()
254+
u.lock()
255+
defer u.unlock()
233256

234257
return u.dependents
235258
}
@@ -240,9 +263,9 @@ func (u *Unit) Dependents() Components {
240263
//
241264
// Unit /path/to/unit (excluded: false, assume applied: false, dependencies: [/dep1, /dep2])
242265
func (u *Unit) String() string {
243-
// Snapshot values under read lock to avoid data races
244-
u.rLock()
245-
defer u.rUnlock()
266+
// Snapshot values under lock to avoid data races
267+
u.lock()
268+
defer u.unlock()
246269

247270
path := u.DisplayPath()
248271
deps := make([]string, 0, len(u.dependencies))
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
package discovery_test
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"path/filepath"
7+
"strings"
8+
"testing"
9+
10+
"github.com/gruntwork-io/terragrunt/internal/component"
11+
"github.com/gruntwork-io/terragrunt/internal/discovery"
12+
"github.com/gruntwork-io/terragrunt/internal/filter"
13+
"github.com/gruntwork-io/terragrunt/pkg/options"
14+
"github.com/gruntwork-io/terragrunt/test/helpers"
15+
"github.com/gruntwork-io/terragrunt/test/helpers/logger"
16+
"github.com/stretchr/testify/require"
17+
)
18+
19+
// TestDiscovery_GraphConcurrentConfigAccessWithRacing reproduces, through the
20+
// public Discover entry point, the data race the per-Unit cfg/reading locks
21+
// guard against: the graph phase reaches a shared unit from several goroutines
22+
// at once, so one goroutine's parse stores the config while others read it.
23+
//
24+
// The shared unit gets a large config and many dependents, repeated across
25+
// several Discover calls, so the read/write overlap is reliably observable; a
26+
// smaller config or fewer iterations make the race intermittent.
27+
//
28+
// To confirm the locks are load-bearing, drop the lock/unlock calls from Unit's
29+
// Config, StoreConfig, Reading, and SetReading and run with -race.
30+
func TestDiscovery_GraphConcurrentConfigAccessWithRacing(t *testing.T) {
31+
t.Parallel()
32+
33+
tmpDir := helpers.TmpDirWOSymlinks(t)
34+
35+
// remote_state is partially decoded during discovery, so a large block is
36+
// walked during parse rather than skipped, which lengthens the parse.
37+
var sharedConfig strings.Builder
38+
39+
sharedConfig.WriteString("remote_state {\n backend = \"local\"\n")
40+
sharedConfig.WriteString(" generate = { path = \"backend.tf\", if_exists = \"overwrite\" }\n config = {\n")
41+
42+
for i := range 8000 {
43+
fmt.Fprintf(&sharedConfig, " k%d = \"v%d\"\n", i, i)
44+
}
45+
46+
sharedConfig.WriteString(" }\n}\n")
47+
48+
vpcDir := filepath.Join(tmpDir, "vpc")
49+
require.NoError(t, os.MkdirAll(vpcDir, 0755))
50+
require.NoError(t, os.WriteFile(filepath.Join(vpcDir, "terragrunt.hcl"), []byte(sharedConfig.String()), 0644))
51+
52+
const leaves = 8
53+
54+
for i := range leaves {
55+
leafDir := filepath.Join(tmpDir, fmt.Sprintf("app%d", i))
56+
require.NoError(t, os.MkdirAll(leafDir, 0755))
57+
require.NoError(t, os.WriteFile(filepath.Join(leafDir, "terragrunt.hcl"), []byte(`
58+
dependency "vpc" {
59+
config_path = "../vpc"
60+
}
61+
`), 0644))
62+
}
63+
64+
l := logger.CreateLogger()
65+
opts := &options.TerragruntOptions{
66+
WorkingDir: tmpDir,
67+
RootWorkingDir: tmpDir,
68+
}
69+
70+
filters, err := filter.ParseFilterQueries(l, []string{"{./**}..."})
71+
require.NoError(t, err)
72+
73+
for range 8 {
74+
d := discovery.NewDiscovery(tmpDir).
75+
WithDiscoveryContext(&component.DiscoveryContext{WorkingDir: tmpDir}).
76+
WithFilters(filters)
77+
78+
_, err := d.Discover(t.Context(), l, opts)
79+
require.NoError(t, err)
80+
}
81+
}

internal/discovery/phase_parse.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,9 @@ func ensureParsed(
124124
return nil
125125
}
126126

127-
return parseComponent(ctx, l, c, opts, discovery)
127+
return unit.GuardConfigParse(func() error {
128+
return parseComponent(ctx, l, c, opts, discovery)
129+
})
128130
}
129131

130132
// ParsePhase parses HCL configurations for filter evaluation.

0 commit comments

Comments
 (0)