Skip to content

Commit dd0bc05

Browse files
authored
feat: move smartrequeue lib from kind clusterprovider (#99)
* move smartrequeue lib from kind clusterprovider * task generate
1 parent 6d91ed9 commit dd0bc05

File tree

10 files changed

+633
-0
lines changed

10 files changed

+633
-0
lines changed

docs/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
- [Readiness Checks](libs/readiness.md)
1616
- [Kubernetes Resource Management](libs/resource.md)
1717
- [Retrying k8s Operations](libs/retry.md)
18+
- [Smart Requeuing of Kubernetes Resources](libs/smartrequeue.md)
1819
- [Kubernetes Resource Status Updating](libs/status.md)
1920
- [Testing](libs/testing.md)
2021
- [Thread Management](libs/threads.md)

docs/libs/smartrequeue.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# Smart Requeuing of Kubernetes Resources
2+
3+
The `pkg/controller/smartrequeue` package contains the smart requeuing logic that was originally implemented [here](https://github.com/openmcp-project/cluster-provider-kind/tree/v0.0.7/pkg/smartrequeue). It allows to requeue reconcile requests with an increasing backoff, similar to what the controller-runtime does when the `Reconcile` method returns an error.
4+
5+
Use `NewStore` in the constructor of the reconciler. During reconciliation, the store's `For` method can be used to get the entry for the passed-in object, which can then generate the reconcile result:
6+
- `Error` returns an error and lets the controller-runtime handle the backoff
7+
- `Never` does not requeue the object
8+
- `Backoff` requeues the object with an increasing backoff every time it is called on the same object
9+
- `Reset` requeues the object, but resets the duration to its minimal value
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
package smartrequeue
2+
3+
import "context"
4+
5+
// contextKey is a type used as a key for storing and retrieving the Entry from the context.
6+
type contextKey struct{}
7+
8+
// NewContext creates a new context with the given Entry.
9+
// This is a utility function for passing Entry instances through context.
10+
func NewContext(ctx context.Context, entry *Entry) context.Context {
11+
return context.WithValue(ctx, contextKey{}, entry)
12+
}
13+
14+
// FromContext retrieves the Entry from the context, if it exists.
15+
// Returns nil if no Entry is found in the context.
16+
func FromContext(ctx context.Context) *Entry {
17+
entry, ok := ctx.Value(contextKey{}).(*Entry)
18+
if !ok {
19+
return nil
20+
}
21+
return entry
22+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
package smartrequeue
2+
3+
import (
4+
"context"
5+
"testing"
6+
"time"
7+
8+
"github.com/stretchr/testify/assert"
9+
)
10+
11+
func TestNewContext(t *testing.T) {
12+
store := NewStore(time.Second, time.Minute, 2)
13+
entry := newEntry(store)
14+
ctx := NewContext(context.Background(), entry)
15+
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")
19+
}
20+
21+
func TestFromContext(t *testing.T) {
22+
store := NewStore(time.Second, time.Minute, 2)
23+
entry := newEntry(store)
24+
ctx := NewContext(context.Background(), entry)
25+
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")
29+
30+
// Test empty context
31+
got = FromContext(context.Background())
32+
assert.Nil(t, got, "Expected nil when no entry is set in context")
33+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
package smartrequeue
2+
3+
import (
4+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
5+
"k8s.io/apimachinery/pkg/runtime"
6+
7+
"sigs.k8s.io/controller-runtime/pkg/client"
8+
)
9+
10+
type dummyObject struct {
11+
metav1.TypeMeta `json:",inline"`
12+
metav1.ObjectMeta `json:"metadata"`
13+
}
14+
15+
var _ client.Object = &dummyObject{}
16+
17+
func (d *dummyObject) DeepCopyObject() runtime.Object {
18+
return &dummyObject{
19+
TypeMeta: d.TypeMeta,
20+
ObjectMeta: *d.DeepCopy(),
21+
}
22+
}
23+
24+
type anotherDummyObject struct {
25+
metav1.TypeMeta `json:",inline"`
26+
metav1.ObjectMeta `json:"metadata"`
27+
}
28+
29+
var _ client.Object = &anotherDummyObject{}
30+
31+
func (d *anotherDummyObject) DeepCopyObject() runtime.Object {
32+
return &anotherDummyObject{
33+
TypeMeta: d.TypeMeta,
34+
ObjectMeta: *d.DeepCopy(),
35+
}
36+
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
package smartrequeue
2+
3+
import (
4+
"time"
5+
6+
ctrl "sigs.k8s.io/controller-runtime"
7+
)
8+
9+
// Entry is used to manage the requeue logic for a specific object.
10+
// It holds the next duration to requeue and the store it belongs to.
11+
type Entry struct {
12+
store *Store
13+
nextDuration time.Duration
14+
}
15+
16+
func newEntry(s *Store) *Entry {
17+
return &Entry{
18+
store: s,
19+
nextDuration: s.minInterval,
20+
}
21+
}
22+
23+
// Error resets the duration to the minInterval and returns an empty Result and the error
24+
// so that the controller-runtime can handle the exponential backoff for errors.
25+
func (e *Entry) Error(err error) (ctrl.Result, error) {
26+
e.nextDuration = e.store.minInterval
27+
return ctrl.Result{}, err
28+
}
29+
30+
// Backoff returns a Result and increments the interval for the next iteration.
31+
func (e *Entry) Backoff() (ctrl.Result, error) {
32+
// Save current duration for result
33+
current := e.nextDuration
34+
35+
// Schedule calculation of next duration
36+
defer e.setNext()
37+
38+
return ctrl.Result{RequeueAfter: current}, nil
39+
}
40+
41+
// Reset resets the duration to the minInterval and returns a Result with that interval.
42+
func (e *Entry) Reset() (ctrl.Result, error) {
43+
e.nextDuration = e.store.minInterval
44+
defer e.setNext()
45+
return ctrl.Result{RequeueAfter: e.nextDuration}, nil
46+
}
47+
48+
// Never deletes the entry from the store and returns an empty Result.
49+
func (e *Entry) Never() (ctrl.Result, error) {
50+
e.store.deleteEntry(e)
51+
return ctrl.Result{}, nil
52+
}
53+
54+
// setNext updates the next requeue duration using exponential backoff.
55+
// It multiplies the current duration by the store's multiplier and ensures
56+
// the result doesn't exceed the configured maximum interval.
57+
func (e *Entry) setNext() {
58+
newDuration := time.Duration(float32(e.nextDuration) * e.store.multiplier)
59+
60+
if newDuration > e.store.maxInterval {
61+
newDuration = e.store.maxInterval
62+
}
63+
64+
e.nextDuration = newDuration
65+
}
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
package smartrequeue
2+
3+
import (
4+
"errors"
5+
"testing"
6+
"time"
7+
8+
"github.com/stretchr/testify/assert"
9+
"github.com/stretchr/testify/require"
10+
ctrl "sigs.k8s.io/controller-runtime"
11+
)
12+
13+
// Helper function to get requeue duration from Result
14+
func getRequeueAfter(res ctrl.Result, _ error) time.Duration {
15+
return res.RequeueAfter.Round(time.Second)
16+
}
17+
18+
func TestEntry_Stable(t *testing.T) {
19+
// Setup
20+
store := NewStore(time.Second, time.Minute, 2)
21+
entry := newEntry(store)
22+
23+
// Test the exponential backoff behavior
24+
t.Run("exponential backoff sequence", func(t *testing.T) {
25+
expectedDurations := []time.Duration{
26+
1 * time.Second,
27+
2 * time.Second,
28+
4 * time.Second,
29+
8 * time.Second,
30+
16 * time.Second,
31+
32 * time.Second,
32+
60 * time.Second, // Capped at maxInterval
33+
60 * time.Second, // Still capped
34+
}
35+
36+
for i, expected := range expectedDurations {
37+
result, err := entry.Backoff()
38+
require.NoError(t, err)
39+
assert.Equal(t, expected, getRequeueAfter(result, err), "Iteration %d should have correct duration", i)
40+
}
41+
})
42+
}
43+
44+
func TestEntry_Progressing(t *testing.T) {
45+
// Setup
46+
minInterval := time.Second
47+
maxInterval := time.Minute
48+
store := NewStore(minInterval, maxInterval, 2)
49+
entry := newEntry(store)
50+
51+
// Ensure state is not at minimum
52+
_, _ = entry.Backoff()
53+
_, _ = entry.Backoff()
54+
55+
// Test progressing resets duration to minimum
56+
t.Run("resets to minimum interval", func(t *testing.T) {
57+
result, err := entry.Reset()
58+
require.NoError(t, err)
59+
assert.Equal(t, minInterval, getRequeueAfter(result, err))
60+
61+
// Second call should also return min interval with small increment
62+
result, err = entry.Reset()
63+
require.NoError(t, err)
64+
assert.Equal(t, minInterval, getRequeueAfter(result, err))
65+
})
66+
67+
// After progressing, Stable should restart exponential backoff
68+
t.Run("stable continues from minimum", func(t *testing.T) {
69+
result, err := entry.Backoff()
70+
require.NoError(t, err)
71+
assert.Equal(t, 2*time.Second, getRequeueAfter(result, err))
72+
73+
result, err = entry.Backoff()
74+
require.NoError(t, err)
75+
assert.Equal(t, 4*time.Second, getRequeueAfter(result, err))
76+
})
77+
}
78+
79+
func TestEntry_Error(t *testing.T) {
80+
// Setup
81+
store := NewStore(time.Second, time.Minute, 2)
82+
entry := newEntry(store)
83+
testErr := errors.New("test error")
84+
85+
// Ensure state is not at minimum
86+
_, _ = entry.Backoff()
87+
_, _ = entry.Backoff()
88+
89+
// Test error handling
90+
t.Run("returns error and resets backoff", func(t *testing.T) {
91+
result, err := entry.Error(testErr)
92+
assert.Equal(t, testErr, err, "Should return the passed error")
93+
assert.Equal(t, 0*time.Second, getRequeueAfter(result, err), "Should have zero requeue time")
94+
})
95+
96+
// After error, stable should continue from minimum
97+
t.Run("stable continues properly after error", func(t *testing.T) {
98+
result, err := entry.Backoff()
99+
require.NoError(t, err)
100+
assert.Equal(t, time.Second, getRequeueAfter(result, err))
101+
102+
result, err = entry.Backoff()
103+
require.NoError(t, err)
104+
assert.Equal(t, 2*time.Second, getRequeueAfter(result, err))
105+
})
106+
}
107+
108+
func TestEntry_Never(t *testing.T) {
109+
// Setup
110+
store := NewStore(time.Second, time.Minute, 2)
111+
entry := newEntry(store)
112+
113+
// Test Never behavior
114+
t.Run("returns empty result", func(t *testing.T) {
115+
result, err := entry.Never()
116+
require.NoError(t, err)
117+
assert.Equal(t, time.Duration(0), getRequeueAfter(result, err))
118+
})
119+
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
package smartrequeue_test
2+
3+
import (
4+
"fmt"
5+
"time"
6+
7+
ctrl "sigs.k8s.io/controller-runtime"
8+
"sigs.k8s.io/controller-runtime/pkg/client"
9+
10+
"github.com/openmcp-project/controller-utils/pkg/controller/smartrequeue"
11+
)
12+
13+
// This example shows how to use the SmartRequeue package in a Kubernetes controller.
14+
func Example_controllerUsage() {
15+
// Create a store with min and max requeue intervals
16+
store := smartrequeue.NewStore(5*time.Second, 10*time.Minute, 2.0)
17+
18+
// In your controller's Reconcile function:
19+
reconcileFunction := func(_ ctrl.Request) (ctrl.Result, error) {
20+
// Create a dummy object representing what you'd get from the client
21+
var obj client.Object // In real code: Get this from the client
22+
23+
// Get the Entry for this specific object
24+
entry := store.For(obj)
25+
26+
// Determine the state of the external resource...
27+
inProgress := false // This would be determined by your logic
28+
errOccurred := false // This would be determined by your logic
29+
30+
// nolint:gocritic
31+
if errOccurred {
32+
// Handle error case
33+
err := fmt.Errorf("something went wrong")
34+
return entry.Error(err)
35+
} else if inProgress {
36+
// Resource is changing - check back soon
37+
return entry.Reset()
38+
} else {
39+
// Resource is stable - gradually back off
40+
return entry.Backoff()
41+
}
42+
}
43+
44+
// Call the reconcile function
45+
_, _ = reconcileFunction(ctrl.Request{})
46+
}

0 commit comments

Comments
 (0)