Skip to content

Commit 05f144f

Browse files
authored
Improve cached dir initialization (#401)
* Add a fast and slow path for cached dir initialization * Improve cachedir cleanup * Use testify in saferepomap unit tests
1 parent 5068cda commit 05f144f

4 files changed

Lines changed: 378 additions & 20 deletions

File tree

pkg/cache/repomap/saferepomap.go

Lines changed: 5 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -25,30 +25,20 @@ type SafeRepoMap struct {
2525
syncMap sync.Map
2626
}
2727

28-
func (s *SafeRepoMap) Store(key repository.RepositoryKey, value repository.Repository) {
29-
s.syncMap.Store(key, value)
30-
}
31-
3228
func (s *SafeRepoMap) Load(key repository.RepositoryKey) (repository.Repository, bool) {
3329
v, ok := s.syncMap.Load(key)
3430
if !ok {
3531
return nil, false
3632
}
37-
if loader, isLoader := v.(*repoLoader); isLoader {
38-
return loader.repo, true
39-
}
40-
return v.(repository.Repository), true
33+
return v.(*repoLoader).repo, true
4134
}
4235

4336
func (s *SafeRepoMap) LoadAndDelete(key repository.RepositoryKey) (repository.Repository, bool) {
4437
v, ok := s.syncMap.LoadAndDelete(key)
4538
if !ok {
4639
return nil, false
4740
}
48-
if loader, isLoader := v.(*repoLoader); isLoader {
49-
return loader.repo, true
50-
}
51-
return v.(repository.Repository), true
41+
return v.(*repoLoader).repo, true
5242
}
5343

5444
func (s *SafeRepoMap) Range(f func(key, value any) bool) {
@@ -63,13 +53,13 @@ type repoLoader struct {
6353

6454
func (s *SafeRepoMap) LoadOrCreate(key repository.RepositoryKey, create func() (repository.Repository, error)) (repository.Repository, error) {
6555
loader := &repoLoader{}
66-
actual, _ := s.syncMap.LoadOrStore(key, loader)
56+
actual, loaded := s.syncMap.LoadOrStore(key, loader)
6757
l := actual.(*repoLoader)
6858

6959
l.once.Do(func() {
7060
l.repo, l.err = create()
71-
if l.err != nil {
72-
// Remove failed entry so subsequent calls can retry
61+
if l.err != nil && !loaded {
62+
// Remove failed entry only if this thread created it, so subsequent calls can retry
7363
s.syncMap.Delete(key)
7464
}
7565
})
Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
// Copyright 2026 The kpt and Nephio Authors
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package repomap
16+
17+
import (
18+
"errors"
19+
"sync"
20+
"sync/atomic"
21+
"testing"
22+
23+
"github.com/nephio-project/porch/pkg/repository"
24+
mockrepository "github.com/nephio-project/porch/test/mockery/mocks/porch/pkg/repository"
25+
"github.com/stretchr/testify/assert"
26+
)
27+
28+
func TestLoadOrCreate_Success(t *testing.T) {
29+
m := &SafeRepoMap{}
30+
key := repository.RepositoryKey{Name: "test-repo"}
31+
32+
repo, err := m.LoadOrCreate(key, func() (repository.Repository, error) {
33+
mock := &mockrepository.MockRepository{}
34+
mock.On("KubeObjectName").Return("created")
35+
return mock, nil
36+
})
37+
38+
assert.NoError(t, err)
39+
assert.NotNil(t, repo)
40+
}
41+
42+
func TestLoadOrCreate_Reuse(t *testing.T) {
43+
m := &SafeRepoMap{}
44+
key := repository.RepositoryKey{Name: "test-repo"}
45+
46+
var callCount int32
47+
create := func() (repository.Repository, error) {
48+
atomic.AddInt32(&callCount, 1)
49+
return &mockrepository.MockRepository{}, nil
50+
}
51+
52+
repo1, err1 := m.LoadOrCreate(key, create)
53+
repo2, err2 := m.LoadOrCreate(key, create)
54+
55+
assert.NoError(t, err1)
56+
assert.NoError(t, err2)
57+
assert.Same(t, repo1, repo2)
58+
assert.Equal(t, int32(1), atomic.LoadInt32(&callCount))
59+
}
60+
61+
func TestLoadOrCreate_ErrorRetry(t *testing.T) {
62+
m := &SafeRepoMap{}
63+
key := repository.RepositoryKey{Name: "test-repo"}
64+
65+
var callCount int32
66+
create := func() (repository.Repository, error) {
67+
count := atomic.AddInt32(&callCount, 1)
68+
if count == 1 {
69+
return nil, errors.New("first attempt failed")
70+
}
71+
return &mockrepository.MockRepository{}, nil
72+
}
73+
74+
// First call should fail
75+
repo1, err1 := m.LoadOrCreate(key, create)
76+
assert.Error(t, err1)
77+
assert.Nil(t, repo1)
78+
79+
// Second call should succeed (retry)
80+
repo2, err2 := m.LoadOrCreate(key, create)
81+
assert.NoError(t, err2)
82+
assert.NotNil(t, repo2)
83+
assert.Equal(t, int32(2), atomic.LoadInt32(&callCount))
84+
}
85+
86+
func TestLoadOrCreate_Concurrent(t *testing.T) {
87+
m := &SafeRepoMap{}
88+
key := repository.RepositoryKey{Name: "test-repo"}
89+
90+
var callCount int32
91+
create := func() (repository.Repository, error) {
92+
atomic.AddInt32(&callCount, 1)
93+
return &mockrepository.MockRepository{}, nil
94+
}
95+
96+
const goroutines = 10
97+
var wg sync.WaitGroup
98+
results := make([]repository.Repository, goroutines)
99+
100+
for i := 0; i < goroutines; i++ {
101+
wg.Add(1)
102+
go func(idx int) {
103+
defer wg.Done()
104+
repo, err := m.LoadOrCreate(key, create)
105+
assert.NoError(t, err)
106+
results[idx] = repo
107+
}(i)
108+
}
109+
110+
wg.Wait()
111+
112+
// All goroutines should get the same instance
113+
for i := 1; i < goroutines; i++ {
114+
assert.Same(t, results[0], results[i])
115+
}
116+
117+
// Create should only be called once
118+
assert.Equal(t, int32(1), atomic.LoadInt32(&callCount))
119+
}
120+
121+
func TestLoadOrCreate_ConcurrentError(t *testing.T) {
122+
m := &SafeRepoMap{}
123+
key := repository.RepositoryKey{Name: "test-repo"}
124+
125+
var callCount int32
126+
create := func() (repository.Repository, error) {
127+
atomic.AddInt32(&callCount, 1)
128+
return nil, errors.New("always fails")
129+
}
130+
131+
const goroutines = 5
132+
var wg sync.WaitGroup
133+
134+
for i := 0; i < goroutines; i++ {
135+
wg.Add(1)
136+
go func() {
137+
defer wg.Done()
138+
_, err := m.LoadOrCreate(key, create)
139+
assert.Error(t, err)
140+
}()
141+
}
142+
143+
wg.Wait()
144+
145+
// With concurrent errors and deletion, multiple goroutines may call create
146+
// This is expected behavior - failed entries are deleted and retried
147+
count := atomic.LoadInt32(&callCount)
148+
assert.GreaterOrEqual(t, count, int32(1))
149+
assert.LessOrEqual(t, count, int32(goroutines))
150+
}
151+
152+
func TestLoad(t *testing.T) {
153+
m := &SafeRepoMap{}
154+
key := repository.RepositoryKey{Name: "test-repo"}
155+
156+
// Load non-existent key
157+
repo, ok := m.Load(key)
158+
assert.False(t, ok)
159+
assert.Nil(t, repo)
160+
161+
// Create a repo
162+
m.LoadOrCreate(key, func() (repository.Repository, error) {
163+
return &mockrepository.MockRepository{}, nil
164+
})
165+
166+
// Load existing key
167+
repo, ok = m.Load(key)
168+
assert.True(t, ok)
169+
assert.NotNil(t, repo)
170+
}
171+
172+
func TestLoadAndDelete(t *testing.T) {
173+
m := &SafeRepoMap{}
174+
key := repository.RepositoryKey{Name: "test-repo"}
175+
176+
// LoadAndDelete non-existent key
177+
repo, ok := m.LoadAndDelete(key)
178+
assert.False(t, ok)
179+
assert.Nil(t, repo)
180+
181+
// Create a repo
182+
m.LoadOrCreate(key, func() (repository.Repository, error) {
183+
return &mockrepository.MockRepository{}, nil
184+
})
185+
186+
// LoadAndDelete existing key
187+
repo, ok = m.LoadAndDelete(key)
188+
assert.True(t, ok)
189+
assert.NotNil(t, repo)
190+
191+
// Verify it's deleted
192+
repo, ok = m.Load(key)
193+
assert.False(t, ok)
194+
}
195+
196+
func TestRange(t *testing.T) {
197+
m := &SafeRepoMap{}
198+
199+
// Create multiple repos
200+
keys := []repository.RepositoryKey{
201+
{Name: "repo1"},
202+
{Name: "repo2"},
203+
{Name: "repo3"},
204+
}
205+
206+
for _, key := range keys {
207+
m.LoadOrCreate(key, func() (repository.Repository, error) {
208+
return &mockrepository.MockRepository{}, nil
209+
})
210+
}
211+
212+
// Range over all entries
213+
var count int
214+
m.Range(func(key, value any) bool {
215+
count++
216+
return true
217+
})
218+
219+
assert.Equal(t, len(keys), count)
220+
221+
// Test early termination
222+
count = 0
223+
m.Range(func(key, value any) bool {
224+
count++
225+
return count < 2 // Stop after 2 iterations
226+
})
227+
228+
assert.Equal(t, 2, count)
229+
}

pkg/externalrepo/git/cachedir_pool.go

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -42,17 +42,30 @@ var globalDirectoryPool = &DirectoryPool{
4242

4343
// GetOrCreateSharedRepository safely initializes or reuses a cached git directory
4444
func (p *DirectoryPool) GetOrCreateSharedRepository(dir, reponame string) (*SharedDirectory, error) {
45+
// Fast path: check if directory already exists
46+
if sharedDir, exists := p.directories.Load(dir); exists {
47+
p.mutex.Lock()
48+
shared := sharedDir.(*SharedDirectory)
49+
shared.refCount++
50+
klog.V(2).Infof("Repo %s is reusing shared directory %s, refCount now: %d", reponame, dir, shared.refCount)
51+
p.mutex.Unlock()
52+
return shared, nil
53+
}
54+
55+
// Slow path: create new directory with write lock - needed to avoid race condition during parallel operations
4556
p.mutex.Lock()
4657
defer p.mutex.Unlock()
4758

48-
// Check if directory already exists
59+
// Double-check after acquiring lock
4960
if sharedDir, exists := p.directories.Load(dir); exists {
5061
shared := sharedDir.(*SharedDirectory)
5162
shared.refCount++
5263
klog.V(2).Infof("Repo %s is reusing shared directory %s, refCount now: %d", reponame, dir, shared.refCount)
5364
return shared, nil
5465
}
5566

67+
klog.V(2).Infof("Repo %s initializing shared directory %s", reponame, dir)
68+
5669
// Initialize repository safely
5770
var repo *git.Repository
5871
if fi, err := os.Stat(dir); err != nil {
@@ -61,15 +74,22 @@ func (p *DirectoryPool) GetOrCreateSharedRepository(dir, reponame string) (*Shar
6174
}
6275
repo, err = initEmptyRepository(dir)
6376
if err != nil {
77+
if removeErr := os.RemoveAll(dir); removeErr != nil {
78+
klog.Errorf("Failed to remove partially created directory %s: %v", dir, removeErr)
79+
}
6480
return nil, err
6581
}
6682
} else if !fi.IsDir() {
6783
return nil, fmt.Errorf("cache location %q is not a directory", dir)
6884
} else {
6985
repo, err = openRepository(dir)
7086
if err != nil {
71-
klog.Errorf("Failed to open repository %s: %v", dir, err)
72-
return nil, fmt.Errorf("open of cached git directory failed in gogit (check the local git cache): %w", err)
87+
if removeErr := os.RemoveAll(dir); removeErr != nil {
88+
klog.Errorf("Failed to open repository %s: %v (also failed to remove corrupted directory: %v)", dir, err, removeErr)
89+
return nil, fmt.Errorf("open of cached git directory failed in gogit (check the local git cache): %w", err)
90+
}
91+
klog.Infof("Removed corrupted cached directory %s, will retry on next attempt", dir)
92+
return nil, fmt.Errorf("open of cached git directory failed in gogit (corrupted cache was removed): %w", err)
7393
}
7494
}
7595

@@ -118,7 +138,7 @@ func (s *SharedDirectory) WithLock(fn func(*git.Repository) error) error {
118138
return fn(s.repo)
119139
}
120140

121-
// WithLock executes function with exclusive access to the cached git directory
141+
// WithRLock executes function with read-only access to the cached git directory
122142
func (s *SharedDirectory) WithRLock(fn func(*git.Repository) error) error {
123143
s.mutex.RLock()
124144
defer s.mutex.RUnlock()

0 commit comments

Comments
 (0)