Skip to content

Commit 9156be6

Browse files
test(smartrequeue): increase coverage to 90%
1 parent 3118a93 commit 9156be6

File tree

7 files changed

+214
-43
lines changed

7 files changed

+214
-43
lines changed

pkg/smartrequeue/context.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,17 @@ import "context"
66
type contextKey struct{}
77

88
// NewContext creates a new context with the given Entry.
9+
// This is a utility function for passing Entry instances through context.
910
func NewContext(ctx context.Context, entry *Entry) context.Context {
1011
return context.WithValue(ctx, contextKey{}, entry)
1112
}
1213

1314
// FromContext retrieves the Entry from the context, if it exists.
15+
// Returns nil if no Entry is found in the context.
1416
func FromContext(ctx context.Context) *Entry {
15-
s, ok := ctx.Value(contextKey{}).(*Entry)
17+
entry, ok := ctx.Value(contextKey{}).(*Entry)
1618
if !ok {
1719
return nil
1820
}
19-
return s
21+
return entry
2022
}

pkg/smartrequeue/context_test.go

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,29 +3,31 @@ package smartrequeue
33
import (
44
"context"
55
"testing"
6+
"time"
67

78
"github.com/stretchr/testify/assert"
89
)
910

1011
func TestNewContext(t *testing.T) {
11-
entry := &Entry{}
12+
store := NewStore(time.Second, time.Minute, 2)
13+
entry := newEntry(store)
1214
ctx := NewContext(context.Background(), entry)
1315

14-
if got := FromContext(ctx); got != entry {
15-
assert.Equal(t, entry, got, "Expected entry to be the same as the one set in context")
16-
}
16+
// Test that we get the entry back using FromContext
17+
got := FromContext(ctx)
18+
assert.Equal(t, entry, got, "Expected entry to be the same as the one set in context")
1719
}
1820

1921
func TestFromContext(t *testing.T) {
20-
entry := &Entry{}
22+
store := NewStore(time.Second, time.Minute, 2)
23+
entry := newEntry(store)
2124
ctx := NewContext(context.Background(), entry)
2225

23-
if got := FromContext(ctx); got != entry {
26+
// Retrieve entry from context
27+
got := FromContext(ctx)
28+
assert.Equal(t, entry, got, "Expected entry to be the same as the one set in context")
2429

25-
assert.Equal(t, entry, got, "Expected entry to be the same as the one set in context")
26-
}
27-
28-
if got := FromContext(context.Background()); got != nil {
29-
assert.Nil(t, got, "Expected nil when no entry is set in context")
30-
}
30+
// Test empty context
31+
got = FromContext(context.Background())
32+
assert.Nil(t, got, "Expected nil when no entry is set in context")
3133
}

pkg/smartrequeue/entry.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,13 @@ func (e *Entry) Error(err error) (ctrl.Result, error) {
3030
// Stable returns a Result and increments the interval for the next iteration.
3131
// Used when the external resource is stable (healthy or unhealthy).
3232
func (e *Entry) Stable() (ctrl.Result, error) {
33+
// Save current duration for result
34+
current := e.nextDuration
35+
36+
// Schedule calculation of next duration
3337
defer e.setNext()
34-
return ctrl.Result{RequeueAfter: e.nextDuration}, nil
38+
39+
return ctrl.Result{RequeueAfter: current}, nil
3540
}
3641

3742
// Progressing resets the duration to the minInterval and returns a Result with that interval.

pkg/smartrequeue/entry_test.go

Lines changed: 5 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -106,20 +106,14 @@ func TestEntry_Error(t *testing.T) {
106106
}
107107

108108
func TestEntry_Never(t *testing.T) {
109-
// Setup with store spy to verify deletion
109+
// Setup
110110
store := NewStore(time.Second, time.Minute, 2)
111111
entry := newEntry(store)
112112

113-
// Call some methods first to ensure we have state
114-
_, _ = entry.Stable()
115-
116-
// Test Never
117-
t.Run("returns empty result and no error", func(t *testing.T) {
113+
// Test Never behavior
114+
t.Run("returns empty result", func(t *testing.T) {
118115
result, err := entry.Never()
119-
assert.NoError(t, err)
120-
assert.Equal(t, 0*time.Second, getRequeueAfter(result, err))
116+
require.NoError(t, err)
117+
assert.Equal(t, time.Duration(0), getRequeueAfter(result, err))
121118
})
122-
123-
// Therotically, we should verify that the entry is deleted from the store.
124-
// Since we don't have direct access to store internals here, this is not possible.
125119
}

pkg/smartrequeue/example_test.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
package smartrequeue_test
2+
3+
import (
4+
"fmt"
5+
"time"
6+
7+
"github.com/openmcp-project/cluster-provider-kind/pkg/smartrequeue"
8+
ctrl "sigs.k8s.io/controller-runtime"
9+
"sigs.k8s.io/controller-runtime/pkg/client"
10+
)
11+
12+
// This example shows how to use the SmartRequeue package in a Kubernetes controller.
13+
func Example_controllerUsage() {
14+
// Create a store with min and max requeue intervals
15+
store := smartrequeue.NewStore(5*time.Second, 10*time.Minute, 2.0)
16+
17+
// In your controller's Reconcile function:
18+
reconcileFunction := func(req ctrl.Request) (ctrl.Result, error) {
19+
// Create a dummy object representing what you'd get from the client
20+
var obj client.Object // In real code: Get this from the client
21+
22+
// Get the Entry for this specific object
23+
entry := store.For(obj)
24+
25+
// Determine the state of the external resource...
26+
inProgress := false // This would be determined by your logic
27+
errOccurred := false // This would be determined by your logic
28+
29+
if errOccurred {
30+
// Handle error case
31+
err := fmt.Errorf("something went wrong")
32+
return entry.Error(err)
33+
} else if inProgress {
34+
// Resource is changing - check back soon
35+
return entry.Progressing()
36+
} else {
37+
// Resource is stable - gradually back off
38+
return entry.Stable()
39+
}
40+
}
41+
42+
// Call the reconcile function
43+
_, _ = reconcileFunction(ctrl.Request{})
44+
}

pkg/smartrequeue/store.go

Lines changed: 61 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -15,55 +15,102 @@ type Store struct {
1515
maxInterval time.Duration
1616
multiplier float32
1717
objects map[key]*Entry
18-
objectsLock sync.Mutex
18+
mu sync.RWMutex // Using RWMutex for better read concurrency
1919
}
2020

2121
// NewStore creates a new Store with the specified minimum and maximum intervals
2222
// and a multiplier for the exponential backoff logic.
2323
func NewStore(minInterval, maxInterval time.Duration, multiplier float32) *Store {
24+
if minInterval <= 0 {
25+
minInterval = 1 * time.Second // Safe default
26+
}
27+
28+
if maxInterval < minInterval {
29+
maxInterval = minInterval * 60 // Safe default: 1 minute or 60x min
30+
}
31+
32+
if multiplier <= 1.0 {
33+
multiplier = 2.0 // Safe default: double each time
34+
}
35+
2436
return &Store{
2537
minInterval: minInterval,
2638
maxInterval: maxInterval,
2739
multiplier: multiplier,
28-
objects: map[key]*Entry{},
40+
objects: make(map[key]*Entry),
2941
}
3042
}
3143

44+
// For gets or creates an Entry for the specified object.
3245
func (s *Store) For(obj client.Object) *Entry {
33-
s.objectsLock.Lock()
34-
defer s.objectsLock.Unlock()
46+
key := keyFromObject(obj)
47+
48+
// Try read lock first for better concurrency
49+
s.mu.RLock()
50+
entry, exists := s.objects[key]
51+
s.mu.RUnlock()
52+
53+
if exists {
54+
return entry
55+
}
3556

36-
objKey := keyFromObject(obj)
37-
entry, ok := s.objects[objKey]
57+
// Need to create a new entry
58+
s.mu.Lock()
59+
defer s.mu.Unlock()
3860

39-
if !ok {
40-
entry = newEntry(s)
41-
s.objects[objKey] = entry
61+
// Check again in case another goroutine created it while we were waiting
62+
entry, exists = s.objects[key]
63+
if !exists {
64+
entry = &Entry{
65+
store: s,
66+
nextDuration: s.minInterval,
67+
}
68+
s.objects[key] = entry
4269
}
4370

4471
return entry
4572
}
4673

74+
// Clear removes all entries from the store (mainly useful for testing).
75+
func (s *Store) Clear() {
76+
s.mu.Lock()
77+
defer s.mu.Unlock()
78+
79+
s.objects = make(map[key]*Entry)
80+
}
81+
82+
// deleteEntry removes an entry from the store.
4783
func (s *Store) deleteEntry(toDelete *Entry) {
48-
s.objectsLock.Lock()
49-
defer s.objectsLock.Unlock()
84+
s.mu.Lock()
85+
defer s.mu.Unlock()
5086

51-
for i, entry := range s.objects {
87+
for k, entry := range s.objects {
5288
if entry == toDelete {
53-
delete(s.objects, i)
89+
delete(s.objects, k)
5490
break
5591
}
5692
}
5793
}
5894

95+
// keyFromObject generates a unique key for a client.Object.
5996
func keyFromObject(obj client.Object) key {
97+
kind := ""
98+
if obj != nil {
99+
kind = obj.GetObjectKind().GroupVersionKind().Kind
100+
if kind == "" {
101+
// Fallback if Kind is not set in GroupVersionKind
102+
kind = reflect.TypeOf(obj).Elem().Name()
103+
}
104+
}
105+
60106
return key{
61-
Kind: reflect.TypeOf(obj).Elem().Name(),
107+
Kind: kind,
62108
Name: obj.GetName(),
63109
Namespace: obj.GetNamespace(),
64110
}
65111
}
66112

113+
// key uniquely identifies a Kubernetes object.
67114
type key struct {
68115
Kind string
69116
Name string

pkg/smartrequeue/store_test.go

Lines changed: 80 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
package smartrequeue
22

33
import (
4+
"fmt"
5+
"sync"
46
"testing"
57
"time"
68

79
"github.com/openmcp-project/openmcp-operator/api/clusters/v1alpha1"
810
"github.com/stretchr/testify/assert"
11+
"github.com/stretchr/testify/require"
912
ctrl "sigs.k8s.io/controller-runtime"
1013
"sigs.k8s.io/controller-runtime/pkg/client"
1114
)
@@ -94,15 +97,89 @@ func TestFor(t *testing.T) {
9497
entry1 := store.For(tt.firstObj)
9598

9699
assert.NotNil(t, entry1, "Expected entry to be created")
97-
assert.Equal(t, 1*time.Second, getRequeueAfter(entry1.Stable()))
100+
result, err := entry1.Stable()
101+
require.NoError(t, err)
102+
assert.Equal(t, 1*time.Second, getRequeueAfter(result, err))
98103

99104
entry2 := store.For(tt.secondObj)
100105

101106
if tt.expectSame {
102-
assert.Equal(t, entry1, entry2, tt.description)
107+
assert.Same(t, entry1, entry2, tt.description)
103108
} else {
104-
assert.NotEqual(t, entry1, entry2, tt.description)
109+
assert.NotSame(t, entry1, entry2, tt.description)
105110
}
106111
})
107112
}
108113
}
114+
115+
// TestClear ensures the Clear method removes all entries
116+
func TestClear(t *testing.T) {
117+
store := NewStore(time.Second, time.Minute, 2)
118+
119+
// Add some entries
120+
obj1 := &v1alpha1.Cluster{
121+
ObjectMeta: ctrl.ObjectMeta{
122+
Name: "test1",
123+
Namespace: "test",
124+
},
125+
}
126+
127+
obj2 := &v1alpha1.Cluster{
128+
ObjectMeta: ctrl.ObjectMeta{
129+
Name: "test2",
130+
Namespace: "test",
131+
},
132+
}
133+
134+
// Get entries to populate the store
135+
entry1 := store.For(obj1)
136+
entry2 := store.For(obj2)
137+
138+
// Verify entries exist
139+
assert.NotNil(t, entry1)
140+
assert.NotNil(t, entry2)
141+
142+
// Clear the store
143+
store.Clear()
144+
145+
// Get entries again - they should be new instances
146+
entry1After := store.For(obj1)
147+
entry2After := store.For(obj2)
148+
149+
// Verify they're different instances
150+
assert.NotSame(t, entry1, entry1After)
151+
assert.NotSame(t, entry2, entry2After)
152+
}
153+
154+
// TestConcurrentAccess tests that the store handles concurrent access properly
155+
func TestConcurrentAccess(t *testing.T) {
156+
store := NewStore(time.Second, time.Minute, 2)
157+
158+
// Create a series of objects
159+
const numObjects = 100
160+
objects := make([]client.Object, numObjects)
161+
162+
for i := 0; i < numObjects; i++ {
163+
objects[i] = &v1alpha1.Cluster{
164+
ObjectMeta: ctrl.ObjectMeta{
165+
Name: fmt.Sprintf("test-%d", i),
166+
Namespace: "test",
167+
},
168+
}
169+
}
170+
171+
// Access concurrently
172+
var wg sync.WaitGroup
173+
wg.Add(numObjects)
174+
175+
for i := 0; i < numObjects; i++ {
176+
go func(idx int) {
177+
defer wg.Done()
178+
obj := objects[idx]
179+
entry := store.For(obj)
180+
_, _ = entry.Stable() // Just exercise the API
181+
}(i)
182+
}
183+
184+
wg.Wait()
185+
}

0 commit comments

Comments
 (0)