Skip to content

Commit 44055e9

Browse files
committed
chore: Adding LockContext
1 parent d1e9af7 commit 44055e9

6 files changed

Lines changed: 285 additions & 4 deletions

File tree

docs/src/content/docs/03-features/07-caching/04-cas.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -206,7 +206,7 @@ For cold clones, where the content is not already in the CAS:
206206
5. Content is stored in the CAS, partitioned by hash prefix
207207
6. The tree structure is read from the CAS and hard links are created to the target directory
208208

209-
If the central Git store cannot be used (for example, the lock cannot be acquired or the fetch fails), Terragrunt logs a warning and falls back to a clone in a temporary directory.
209+
Concurrent units that target the same remote URL share one fetch instead of cloning in parallel, so the objects are typically transferred once and reused. If the shared fetch hangs or fails, Terragrunt logs a warning and falls back to a clone in a temporary directory.
210210

211211
#### Warm Clones
212212

docs/src/data/changelog/v1.0.4/cas-central-git-store.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ category: "experiments-updated"
77

88
CAS now keeps one bare Git repository per remote URL inside its store, under `~/.cache/terragrunt/cas/store/git/` on Linux by default. See [Storage](/features/caching/cas#storage) for where this lives on macOS and Windows. On a cache miss, Terragrunt fetches just the requested ref into that repository instead of running a fresh shallow clone into a temporary directory. Repeated misses against the same remote reuse the existing pack files, so fetching a second ref from the same repository transfers only the new objects.
99

10-
Concurrent Terragrunt invocations are coordinated by a per-URL lock so pack-file writes do not interleave. If the central store cannot be used for any reason, Terragrunt logs a warning and falls back to the previous temporary-clone path so cloning still succeeds.
10+
Concurrent Terragrunt runs against the same remote URL share one fetch instead of cloning in parallel; later runs reuse what the first one transferred. If the shared fetch hangs or fails, Terragrunt logs a warning and falls back to a temporary clone so cloning still succeeds.
1111

1212
You can reclaim space at any time by deleting the `git/` subdirectory:
1313

internal/cas/gitstore.go

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,19 +6,32 @@ import (
66
"encoding/hex"
77
"fmt"
88
"path/filepath"
9+
"time"
910

1011
"github.com/gruntwork-io/terragrunt/internal/errors"
1112
"github.com/gruntwork-io/terragrunt/internal/git"
1213
"github.com/gruntwork-io/terragrunt/internal/vfs"
1314
"github.com/gruntwork-io/terragrunt/pkg/log"
1415
)
1516

16-
const gitStoreURLHashLen = 16
17+
const (
18+
gitStoreURLHashLen = 16
19+
// gitStoreLockTimeout bounds how long EnsureRef waits for the per-URL
20+
// lock before giving up and letting the caller fall back to a temporary
21+
// clone. Generous enough to outlast a typical fetch, short enough that a
22+
// hung holder does not stall every concurrent unit indefinitely.
23+
gitStoreLockTimeout = 5 * time.Minute
24+
)
1725

1826
// GitStore keeps one bare git repository per remote URL on disk so CAS cache
1927
// misses can issue an incremental git fetch instead of a full shallow clone.
2028
// Each per-URL repository is gated by an exclusive flock because pack-file
2129
// writes are not safe to interleave with concurrent reads of the same repo.
30+
// EnsureRef waits up to gitStoreLockTimeout for the lock; on context
31+
// cancellation or timeout the caller can fall back to a temporary clone
32+
// rather than block indefinitely on a hung holder. After acquiring the
33+
// lock, EnsureRef re-checks for the requested object so a unit that simply
34+
// waited out a peer's fetch can proceed without re-doing the work.
2235
// The flock is held from EnsureRef return until the caller releases it.
2336
type GitStore struct {
2437
runner *git.GitRunner
@@ -62,7 +75,10 @@ func (s *GitStore) EnsureRef(
6275
return "", nil, fmt.Errorf("create git store entry %s: %w", dir, errors.Join(ErrGitStorePath, err))
6376
}
6477

65-
unlocker, err := vfs.Lock(fs, lockPath)
78+
lockCtx, cancel := context.WithTimeout(ctx, gitStoreLockTimeout)
79+
defer cancel()
80+
81+
unlocker, err := vfs.LockContext(lockCtx, fs, lockPath)
6682
if err != nil {
6783
return "", nil, fmt.Errorf("lock git store for %s: %w", url, errors.Join(ErrGitStoreLock, err))
6884
}

internal/cas/gitstore_test.go

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
package cas_test
22

33
import (
4+
"context"
5+
"errors"
46
"path/filepath"
57
"strings"
68
"sync"
79
"testing"
10+
"time"
811

912
"github.com/gruntwork-io/terragrunt/internal/cas"
1013
"github.com/gruntwork-io/terragrunt/internal/git"
@@ -108,6 +111,69 @@ func TestGitStoreEnsureRefConcurrentSameURLWithRacing(t *testing.T) {
108111
}
109112
}
110113

114+
func TestGitStoreEnsureRef_LockHeldRespectsContextCancellation(t *testing.T) {
115+
t.Parallel()
116+
117+
url := startTestServer(t)
118+
hash := resolveHead(t, url)
119+
120+
store, fs, root := newTestGitStore(t)
121+
require.NotEmpty(t, root)
122+
123+
l := logger.CreateLogger()
124+
125+
// First caller takes the per-URL lock and holds it.
126+
repoPath, unlock, err := store.EnsureRef(t.Context(), l, fs, url, "main", hash, 0)
127+
require.NoError(t, err)
128+
require.NotEmpty(t, repoPath)
129+
t.Cleanup(func() { _ = unlock.Unlock() })
130+
131+
// Second caller arrives with a short deadline. With the lock held it
132+
// must return a context error rather than block.
133+
ctx, cancel := context.WithTimeout(t.Context(), 100*time.Millisecond)
134+
defer cancel()
135+
136+
start := time.Now()
137+
138+
_, _, err = store.EnsureRef(ctx, l, fs, url, "main", hash, 0)
139+
require.Error(t, err)
140+
assert.Less(t, time.Since(start), 5*time.Second, "EnsureRef should not block past the context deadline")
141+
assert.True(
142+
t,
143+
errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled),
144+
"expected context error, got %v", err,
145+
)
146+
}
147+
148+
func TestGitStoreEnsureRefLockReleaseAllowsWaiterToProceedWithRacing(t *testing.T) {
149+
t.Parallel()
150+
151+
url := startTestServer(t)
152+
hash := resolveHead(t, url)
153+
154+
store, fs, root := newTestGitStore(t)
155+
require.NotEmpty(t, root)
156+
157+
l := logger.CreateLogger()
158+
159+
_, unlock, err := store.EnsureRef(t.Context(), l, fs, url, "main", hash, 0)
160+
require.NoError(t, err)
161+
162+
// Release the holder after a short delay so the waiter sees the lock open.
163+
go func() {
164+
time.Sleep(50 * time.Millisecond)
165+
166+
_ = unlock.Unlock()
167+
}()
168+
169+
ctx, cancel := context.WithTimeout(t.Context(), 30*time.Second)
170+
defer cancel()
171+
172+
_, unlock2, err := store.EnsureRef(ctx, l, fs, url, "main", hash, 0)
173+
require.NoError(t, err)
174+
require.NoError(t, unlock2.Unlock())
175+
}
176+
111177
func TestGitStoreEnsureRef_FetchFailureSurfacesError(t *testing.T) {
112178
t.Parallel()
113179

internal/vfs/vfs.go

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ package vfs
55
import (
66
"archive/zip"
77
"bytes"
8+
"context"
89
"errors"
910
"fmt"
1011
"io"
@@ -15,6 +16,7 @@ import (
1516
"sort"
1617
"strings"
1718
"sync"
19+
"time"
1820

1921
"github.com/charlievieth/fastwalk"
2022
"github.com/gofrs/flock"
@@ -48,6 +50,16 @@ type Locker interface {
4850
TryLock(name string) (Unlocker, bool, error)
4951
}
5052

53+
// ContextLocker is an optional interface for filesystems whose locks can be
54+
// acquired with a context. Implementations should poll/retry until the lock
55+
// is acquired or ctx is canceled. On ctx cancellation, the returned error
56+
// wraps ctx.Err(). Implementations also impose a hard upper bound on the
57+
// total wait (see [maxLockWait]), so a never-canceled ctx will still return
58+
// after that bound elapses.
59+
type ContextLocker interface {
60+
LockContext(ctx context.Context, name string) (Unlocker, error)
61+
}
62+
5163
// ErrNoHardLink is returned when a filesystem does not support hard links.
5264
var ErrNoHardLink = errors.New("hard link not supported")
5365

@@ -159,6 +171,23 @@ func TryLock(fs FS, name string) (Unlocker, bool, error) {
159171
return locker.TryLock(name)
160172
}
161173

174+
// LockContext acquires a lock for the given name, blocking until it is
175+
// available or ctx is canceled. Filesystems that implement ContextLocker
176+
// use their native context-aware path; otherwise the call falls back to a
177+
// blocking Lock without context support.
178+
//
179+
// ContextLocker implementations cap the total wait at [maxLockWait], so the
180+
// call returns after that bound elapses even when ctx is never canceled.
181+
// Callers that want a shorter deadline should pass a ctx with their own
182+
// timeout.
183+
func LockContext(ctx context.Context, fs FS, name string) (Unlocker, error) {
184+
if cl, ok := fs.(ContextLocker); ok {
185+
return cl.LockContext(ctx, name)
186+
}
187+
188+
return Lock(fs, name)
189+
}
190+
162191
// WalkDirParallelOption configures a [WalkDirParallel] call.
163192
type WalkDirParallelOption func(*walkDirParallelConfig)
164193

@@ -288,6 +317,43 @@ func (fs *osFS) TryLock(name string) (Unlocker, bool, error) {
288317
return l, true, nil
289318
}
290319

320+
const (
321+
// osFlockRetryDelay is how often osFS.LockContext polls for the flock
322+
// when the lock is held by another process. gofrs/flock uses a syscall
323+
// per attempt, so the tick is coarse enough to limit churn while still
324+
// reacting quickly when the holder releases.
325+
osFlockRetryDelay = 50 * time.Millisecond
326+
327+
// memMapLockRetryDelay is how often memMapFS.LockContext retries its
328+
// in-process sync.Mutex TryLock. The retry is essentially free, so the
329+
// tick is tighter than [osFlockRetryDelay] to keep tests snappy.
330+
memMapLockRetryDelay = 10 * time.Millisecond
331+
332+
// maxLockWait is the hard upper bound on any LockContext call regardless
333+
// of the caller's context. It guarantees the retry loop terminates even
334+
// if a caller passes a never-canceled context and the lock is permanently
335+
// held by another process.
336+
maxLockWait = 30 * time.Minute
337+
)
338+
339+
func (fs *osFS) LockContext(ctx context.Context, name string) (Unlocker, error) {
340+
ctx, cancel := context.WithTimeout(ctx, maxLockWait)
341+
defer cancel()
342+
343+
l := flock.New(name)
344+
345+
acquired, err := l.TryLockContext(ctx, osFlockRetryDelay)
346+
if err != nil {
347+
return nil, err
348+
}
349+
350+
if !acquired {
351+
return nil, ctx.Err()
352+
}
353+
354+
return l, nil
355+
}
356+
291357
// memMapFS wraps afero.MemMapFs with in-memory symlink support.
292358
type memMapFS struct {
293359
afero.Fs
@@ -362,6 +428,25 @@ func (fs *memMapFS) TryLock(name string) (Unlocker, bool, error) {
362428
return l, true, nil
363429
}
364430

431+
func (fs *memMapFS) LockContext(ctx context.Context, name string) (Unlocker, error) {
432+
ctx, cancel := context.WithTimeout(ctx, maxLockWait)
433+
defer cancel()
434+
435+
l := fs.getOrCreateLock(name)
436+
437+
for {
438+
if l.mu.TryLock() {
439+
return l, nil
440+
}
441+
442+
select {
443+
case <-ctx.Done():
444+
return nil, ctx.Err()
445+
case <-time.After(memMapLockRetryDelay):
446+
}
447+
}
448+
}
449+
365450
func (fs *memMapFS) getOrCreateLock(name string) *memLock {
366451
fs.locksMu.Lock()
367452
defer fs.locksMu.Unlock()

0 commit comments

Comments
 (0)