Skip to content

Commit 504d5ab

Browse files
Feat: Prevent multiple identical inflight channel search requests (#16)
* add new cache type with loader to avoid duplicate concurrent requests to the Sift API
1 parent c60e7ff commit 504d5ab

6 files changed

Lines changed: 415 additions & 116 deletions

File tree

pkg/plugin/cache_utils.go

Lines changed: 62 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@ package plugin
22

33
import (
44
"fmt"
5+
"github.com/grafana/grafana-plugin-sdk-go/backend"
6+
"github.com/grafana/grafana-plugin-sdk-go/backend/log"
7+
"github.com/patrickmn/go-cache"
58
"math/rand"
9+
"sync"
610
"time"
7-
8-
"github.com/patrickmn/go-cache"
911
)
1012

1113
// TypedCache is a type-safe wrapper around go-cache
@@ -51,7 +53,6 @@ func (tc *TypedCache[K, V]) Get(key K) (V, bool) {
5153
if !ok {
5254
return result, false
5355
}
54-
5556
return typedValue, true
5657
}
5758

@@ -72,3 +73,61 @@ func (tc *TypedCache[K, V]) getRandomizedTimeToLive() time.Duration {
7273
}
7374
return time.Duration(rand.Intn(int(tc.maxTtl-tc.minTtl))) + tc.minTtl
7475
}
76+
77+
type TypedCacheWithLoader[K any, V any, C comparable] struct {
78+
*TypedCache[C, V]
79+
mu *sync.Mutex
80+
keyToComparable func(K) C
81+
loading map[C]func() (V, error)
82+
loader func(*SiftDatasource, backend.PluginContext, K) (V, error)
83+
}
84+
85+
func NewTypedCacheWithLoader[K any, V any, C comparable](typedCache *TypedCache[C, V], loader func(*SiftDatasource, backend.PluginContext, K) (V, error), keyToComparable func(K) C) *TypedCacheWithLoader[K, V, C] {
86+
return &TypedCacheWithLoader[K, V, C]{
87+
TypedCache: typedCache,
88+
mu: &sync.Mutex{},
89+
keyToComparable: keyToComparable,
90+
loading: make(map[C]func() (V, error)),
91+
loader: loader,
92+
}
93+
}
94+
95+
// GetOrWait retrieves an item from the cache and waits on any other goroutine that is setting the value
96+
func (tc *TypedCacheWithLoader[K, V, C]) GetOrWait(d *SiftDatasource, ctx backend.PluginContext, key K) (V, error) {
97+
tc.mu.Lock()
98+
comparableKey := tc.keyToComparable(key)
99+
value, found := tc.cache.Get(fmt.Sprintf("%v", comparableKey))
100+
if found {
101+
if typedValue, ok := value.(V); ok {
102+
tc.mu.Unlock()
103+
return typedValue, nil
104+
}
105+
}
106+
107+
// Check if we are already loading the value
108+
load := tc.loading[comparableKey]
109+
if load != nil {
110+
tc.mu.Unlock()
111+
log.DefaultLogger.Debug("waiting for pending cache load", "key", comparableKey)
112+
return load()
113+
}
114+
115+
// Haven't started loading it
116+
log.DefaultLogger.Debug("initiating new cache load", "key", comparableKey)
117+
load = sync.OnceValues(func() (V, error) {
118+
v, err := tc.loader(d, ctx, key)
119+
tc.mu.Lock()
120+
defer tc.mu.Unlock()
121+
122+
delete(tc.loading, comparableKey)
123+
if err != nil {
124+
return v, err
125+
}
126+
tc.Set(comparableKey, v)
127+
return v, nil
128+
})
129+
tc.loading[comparableKey] = load
130+
tc.mu.Unlock()
131+
return load()
132+
133+
}

pkg/plugin/cache_utils_test.go

Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
11
package plugin
22

33
import (
4+
"errors"
5+
"sync"
46
"testing"
57
"time"
8+
9+
"github.com/grafana/grafana-plugin-sdk-go/backend"
610
)
711

812
func TestTypedCache_BasicOperations(t *testing.T) {
@@ -65,3 +69,237 @@ func TestTypedCache_WithStructKeys(t *testing.T) {
6569
t.Errorf("Expected 200, got %d", value)
6670
}
6771
}
72+
73+
func TestTypedCacheWithLoader_GetOrWait_CacheHit(t *testing.T) {
74+
// Create a typed cache
75+
cache := NewTypedCache[string, string](5*time.Minute, 10*time.Minute)
76+
77+
// Pre-populate the cache
78+
cache.Set("test-key", "cached-value")
79+
80+
// Create a loader that should not be called
81+
loader := func(d *SiftDatasource, ctx backend.PluginContext, key string) (string, error) {
82+
t.Error("Loader should not be called when value is in cache")
83+
return "", errors.New("should not be called")
84+
}
85+
86+
// Create cache with loader
87+
cacheWithLoader := NewTypedCacheWithLoader(cache, loader, func(k string) string { return k })
88+
89+
// Mock datasource and context
90+
mockDatasource := &SiftDatasource{}
91+
mockContext := backend.PluginContext{}
92+
93+
// Test GetOrWait - should return cached value
94+
result, err := cacheWithLoader.GetOrWait(mockDatasource, mockContext, "test-key")
95+
96+
if err != nil {
97+
t.Errorf("Expected no error, got %v", err)
98+
}
99+
if result != "cached-value" {
100+
t.Errorf("Expected 'cached-value', got %s", result)
101+
}
102+
}
103+
104+
func TestTypedCacheWithLoader_GetOrWait_CacheMiss(t *testing.T) {
105+
// Create an empty typed cache
106+
cache := NewTypedCache[string, string](5*time.Minute, 10*time.Minute)
107+
108+
// Create a loader that returns a value
109+
loaderCalled := false
110+
loader := func(d *SiftDatasource, ctx backend.PluginContext, key string) (string, error) {
111+
loaderCalled = true
112+
if key == "test-key" {
113+
return "loaded-value", nil
114+
}
115+
return "", errors.New("unexpected key")
116+
}
117+
118+
// Create cache with loader
119+
cacheWithLoader := NewTypedCacheWithLoader(cache, loader, func(k string) string { return k })
120+
121+
// Mock datasource and context
122+
mockDatasource := &SiftDatasource{}
123+
mockContext := backend.PluginContext{}
124+
125+
// Test GetOrWait - should call loader and return loaded value
126+
result, err := cacheWithLoader.GetOrWait(mockDatasource, mockContext, "test-key")
127+
128+
if err != nil {
129+
t.Errorf("Expected no error, got %v", err)
130+
}
131+
if result != "loaded-value" {
132+
t.Errorf("Expected 'loaded-value', got %s", result)
133+
}
134+
if !loaderCalled {
135+
t.Error("Expected loader to be called")
136+
}
137+
138+
// Verify value was cached
139+
cachedValue, found := cache.Get("test-key")
140+
if !found {
141+
t.Error("Expected value to be cached")
142+
}
143+
if cachedValue != "loaded-value" {
144+
t.Errorf("Expected cached value 'loaded-value', got %s", cachedValue)
145+
}
146+
}
147+
148+
func TestTypedCacheWithLoader_GetOrWait_LoaderError(t *testing.T) {
149+
// Create an empty typed cache
150+
cache := NewTypedCache[string, string](5*time.Minute, 10*time.Minute)
151+
152+
// Create a loader that returns an error
153+
expectedError := errors.New("loader failed")
154+
loader := func(d *SiftDatasource, ctx backend.PluginContext, key string) (string, error) {
155+
return "", expectedError
156+
}
157+
158+
// Create cache with loader
159+
cacheWithLoader := NewTypedCacheWithLoader(cache, loader, func(k string) string { return k })
160+
161+
// Mock datasource and context
162+
mockDatasource := &SiftDatasource{}
163+
mockContext := backend.PluginContext{}
164+
165+
// Test GetOrWait - should return error from loader
166+
result, err := cacheWithLoader.GetOrWait(mockDatasource, mockContext, "test-key")
167+
168+
if err != expectedError {
169+
t.Errorf("Expected error %v, got %v", expectedError, err)
170+
}
171+
if result != "" {
172+
t.Errorf("Expected empty result on error, got %s", result)
173+
}
174+
175+
// Verify value was not cached on error
176+
_, found := cache.Get("test-key")
177+
if found {
178+
t.Error("Expected value not to be cached on loader error")
179+
}
180+
}
181+
182+
func TestTypedCacheWithLoader_GetOrWait_ConcurrentLoads(t *testing.T) {
183+
// Create an empty typed cache
184+
cache := NewTypedCache[string, string](5*time.Minute, 10*time.Minute)
185+
186+
// Create a loader that simulates slow loading
187+
loaderCallCount := 0
188+
var loaderMutex sync.Mutex
189+
loader := func(d *SiftDatasource, ctx backend.PluginContext, key string) (string, error) {
190+
loaderMutex.Lock()
191+
loaderCallCount++
192+
loaderMutex.Unlock()
193+
194+
// Simulate slow operation
195+
time.Sleep(100 * time.Millisecond)
196+
return "loaded-value", nil
197+
}
198+
199+
// Create cache with loader
200+
cacheWithLoader := NewTypedCacheWithLoader(cache, loader, func(k string) string { return k })
201+
202+
// Mock datasource and context
203+
mockDatasource := &SiftDatasource{}
204+
mockContext := backend.PluginContext{}
205+
206+
// Launch multiple concurrent GetOrWait calls
207+
const numGoroutines = 5
208+
var wg sync.WaitGroup
209+
results := make([]string, numGoroutines)
210+
errors := make([]error, numGoroutines)
211+
212+
for i := 0; i < numGoroutines; i++ {
213+
wg.Add(1)
214+
go func(index int) {
215+
defer wg.Done()
216+
result, err := cacheWithLoader.GetOrWait(mockDatasource, mockContext, "test-key")
217+
results[index] = result
218+
errors[index] = err
219+
}(i)
220+
}
221+
222+
wg.Wait()
223+
224+
// Verify all calls succeeded and returned the same value
225+
for i := 0; i < numGoroutines; i++ {
226+
if errors[i] != nil {
227+
t.Errorf("Goroutine %d got error: %v", i, errors[i])
228+
}
229+
if results[i] != "loaded-value" {
230+
t.Errorf("Goroutine %d got result %s, expected 'loaded-value'", i, results[i])
231+
}
232+
}
233+
234+
// Verify loader was called only once despite multiple concurrent requests
235+
loaderMutex.Lock()
236+
if loaderCallCount != 1 {
237+
t.Errorf("Expected loader to be called exactly once, but was called %d times", loaderCallCount)
238+
}
239+
loaderMutex.Unlock()
240+
241+
// Verify value was cached
242+
cachedValue, found := cache.Get("test-key")
243+
if !found {
244+
t.Error("Expected value to be cached")
245+
}
246+
if cachedValue != "loaded-value" {
247+
t.Errorf("Expected cached value 'loaded-value', got %s", cachedValue)
248+
}
249+
}
250+
251+
func TestTypedCacheWithLoader_GetOrWait_WithComplexTypes(t *testing.T) {
252+
// Test with complex key and value types
253+
type TestKey struct {
254+
AssetID string
255+
RunID string
256+
}
257+
258+
type TestValue struct {
259+
Data string
260+
Timestamp time.Time
261+
}
262+
263+
// Create cache with complex types
264+
cache := NewTypedCache[string, TestValue](5*time.Minute, 10*time.Minute)
265+
266+
// Create a loader
267+
loader := func(d *SiftDatasource, ctx backend.PluginContext, key TestKey) (TestValue, error) {
268+
return TestValue{
269+
Data: "complex-data-" + key.AssetID,
270+
Timestamp: time.Now(),
271+
}, nil
272+
}
273+
274+
// Key conversion function
275+
keyToComparable := func(k TestKey) string {
276+
return k.AssetID + ":" + k.RunID
277+
}
278+
279+
// Create cache with loader
280+
cacheWithLoader := NewTypedCacheWithLoader(cache, loader, keyToComparable)
281+
282+
// Mock datasource and context
283+
mockDatasource := &SiftDatasource{}
284+
mockContext := backend.PluginContext{}
285+
286+
// Test with complex key
287+
testKey := TestKey{AssetID: "asset123", RunID: "run456"}
288+
result, err := cacheWithLoader.GetOrWait(mockDatasource, mockContext, testKey)
289+
290+
if err != nil {
291+
t.Errorf("Expected no error, got %v", err)
292+
}
293+
if result.Data != "complex-data-asset123" {
294+
t.Errorf("Expected 'complex-data-asset123', got %s", result.Data)
295+
}
296+
297+
// Verify caching with the converted key
298+
cachedValue, found := cache.Get("asset123:run456")
299+
if !found {
300+
t.Error("Expected value to be cached with converted key")
301+
}
302+
if cachedValue.Data != "complex-data-asset123" {
303+
t.Errorf("Expected cached data 'complex-data-asset123', got %s", cachedValue.Data)
304+
}
305+
}

0 commit comments

Comments
 (0)