Skip to content

Commit a9511ed

Browse files
authored
FFM-11660 Add configurable size and clearing schedule to the seen targets map (#165)
1 parent f2362b4 commit a9511ed

File tree

9 files changed

+220
-69
lines changed

9 files changed

+220
-69
lines changed

.harness/ffgolangserversdk.yaml

+1-1
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,7 @@ pipeline:
176176
dockerfile: ff-sdk-testgrid/go/Dockerfile
177177
context: ff-sdk-testgrid/go
178178
buildArgs:
179-
SDK_VERSION: v0.1.24
179+
SDK_VERSION: v0.1.25
180180
BUILD_MODE: local
181181
resources:
182182
limits:

analyticsservice/analytics.go

+49-20
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ const (
2626
variationValueAttribute string = "featureValue"
2727
targetAttribute string = "target"
2828
sdkVersionAttribute string = "SDK_VERSION"
29-
SdkVersion string = "0.1.24"
29+
SdkVersion string = "0.1.25"
3030
sdkTypeAttribute string = "SDK_TYPE"
3131
sdkType string = "server"
3232
sdkLanguageAttribute string = "SDK_LANGUAGE"
@@ -46,6 +46,12 @@ type SafeAnalyticsCache[K comparable, V any] interface {
4646
iterate(func(K, V))
4747
}
4848

49+
// SafeSeenTargetsCache extends SafeAnalyticsCache and adds behavior specific to seen targets
50+
type SafeSeenTargetsCache[K comparable, V any] interface {
51+
SafeAnalyticsCache[K, V]
52+
isLimitExceeded() bool
53+
}
54+
4955
type analyticsEvent struct {
5056
target *evaluation.Target
5157
featureConfig *rest.FeatureConfig
@@ -55,33 +61,35 @@ type analyticsEvent struct {
5561

5662
// AnalyticsService provides a way to cache and send analytics to the server
5763
type AnalyticsService struct {
58-
analyticsChan chan analyticsEvent
59-
evaluationAnalytics SafeAnalyticsCache[string, analyticsEvent]
60-
targetAnalytics SafeAnalyticsCache[string, evaluation.Target]
61-
seenTargets SafeAnalyticsCache[string, bool]
62-
logEvaluationLimitReached atomic.Bool
63-
logTargetLimitReached atomic.Bool
64-
timeout time.Duration
65-
logger logger.Logger
66-
metricsClient metricsclient.ClientWithResponsesInterface
67-
environmentID string
64+
analyticsChan chan analyticsEvent
65+
evaluationAnalytics SafeAnalyticsCache[string, analyticsEvent]
66+
targetAnalytics SafeAnalyticsCache[string, evaluation.Target]
67+
seenTargets SafeSeenTargetsCache[string, bool]
68+
logEvaluationLimitReached atomic.Bool
69+
logTargetLimitReached atomic.Bool
70+
timeout time.Duration
71+
logger logger.Logger
72+
metricsClient metricsclient.ClientWithResponsesInterface
73+
environmentID string
74+
seenTargetsClearingInterval time.Duration
6875
}
6976

7077
// NewAnalyticsService creates and starts a analytics service to send data to the client
71-
func NewAnalyticsService(timeout time.Duration, logger logger.Logger) *AnalyticsService {
78+
func NewAnalyticsService(timeout time.Duration, logger logger.Logger, seenTargetsMaxSize int, seenTargetsClearingSchedule time.Duration) *AnalyticsService {
7279
serviceTimeout := timeout
7380
if timeout < 60*time.Second {
7481
serviceTimeout = 60 * time.Second
7582
} else if timeout > 1*time.Hour {
7683
serviceTimeout = 1 * time.Hour
7784
}
7885
as := AnalyticsService{
79-
analyticsChan: make(chan analyticsEvent),
80-
evaluationAnalytics: newSafeEvaluationAnalytics(),
81-
targetAnalytics: newSafeTargetAnalytics(),
82-
seenTargets: newSafeSeenTargets(),
83-
timeout: serviceTimeout,
84-
logger: logger,
86+
analyticsChan: make(chan analyticsEvent),
87+
evaluationAnalytics: newSafeEvaluationAnalytics(),
88+
targetAnalytics: newSafeTargetAnalytics(),
89+
seenTargets: newSafeSeenTargets(seenTargetsMaxSize),
90+
timeout: serviceTimeout,
91+
logger: logger,
92+
seenTargetsClearingInterval: seenTargetsClearingSchedule,
8593
}
8694
go as.listener()
8795

@@ -94,6 +102,7 @@ func (as *AnalyticsService) Start(ctx context.Context, client metricsclient.Clie
94102
as.metricsClient = client
95103
as.environmentID = environmentID
96104
go as.startTimer(ctx)
105+
go as.startSeenTargetsClearingSchedule(ctx, as.seenTargetsClearingInterval)
97106
}
98107

99108
func (as *AnalyticsService) startTimer(ctx context.Context) {
@@ -103,6 +112,7 @@ func (as *AnalyticsService) startTimer(ctx context.Context) {
103112
timeStamp := time.Now().UnixNano() / (int64(time.Millisecond) / int64(time.Nanosecond))
104113
as.sendDataAndResetCache(ctx, timeStamp)
105114
case <-ctx.Done():
115+
close(as.analyticsChan)
106116
as.logger.Infof("%s Metrics stopped", sdk_codes.MetricsStopped)
107117
return
108118
}
@@ -149,9 +159,12 @@ func (as *AnalyticsService) listener() {
149159
}
150160

151161
// Check if target has been seen
152-
_, seen := as.seenTargets.get(ad.target.Identifier)
162+
if _, seen := as.seenTargets.get(ad.target.Identifier); seen {
163+
continue
164+
}
153165

154-
if seen {
166+
// Check if seen targets limit has been hit
167+
if as.seenTargets.isLimitExceeded() {
155168
continue
156169
}
157170

@@ -314,6 +327,22 @@ func (as *AnalyticsService) processTargetMetrics(targetAnalytics SafeAnalyticsCa
314327
return targetData
315328
}
316329

330+
func (as *AnalyticsService) startSeenTargetsClearingSchedule(ctx context.Context, clearingInterval time.Duration) {
331+
ticker := time.NewTicker(clearingInterval)
332+
333+
for {
334+
select {
335+
case <-ticker.C:
336+
as.logger.Debugf("Clearing seen targets")
337+
as.seenTargets.clear()
338+
339+
case <-ctx.Done():
340+
ticker.Stop()
341+
return
342+
}
343+
}
344+
}
345+
317346
func getEvaluationAnalyticKey(event analyticsEvent) string {
318347
return fmt.Sprintf("%s-%s-%s-%s", event.featureConfig.Feature, event.variation.Identifier, event.variation.Value, globalTarget)
319348
}

analyticsservice/analytics_test.go

+1-1
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,7 @@ func TestListenerHandlesEventsCorrectly(t *testing.T) {
120120

121121
for _, tc := range testCases {
122122
t.Run(tc.name, func(t *testing.T) {
123-
service := NewAnalyticsService(1*time.Minute, noOpLogger)
123+
service := NewAnalyticsService(1*time.Minute, noOpLogger, 10, time.Hour)
124124
defer close(service.analyticsChan)
125125

126126
// Start the listener in a goroutine

analyticsservice/safe_maps_test.go

+86-3
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package analyticsservice
22

33
import (
4+
"fmt"
45
"reflect"
56
"sync"
67
"testing"
@@ -81,13 +82,95 @@ func TestSafeTargetAnalytics(t *testing.T) {
8182
}
8283

8384
func TestSafeSeenTargets(t *testing.T) {
84-
s := newSafeSeenTargets()
85+
// Initialize with a small maxSize for testing
86+
maxSize := 3
87+
s := newSafeSeenTargets(maxSize).(SafeSeenTargetsCache[string, bool])
88+
8589
testData := map[string]bool{
8690
"target1": true,
8791
"target21": true,
8892
"target3": true,
89-
"target4": true,
9093
}
9194

92-
testMapOperations[string, bool](t, s, testData)
95+
// Insert items and ensure limit is not exceeded
96+
for key, value := range testData {
97+
s.set(key, value)
98+
}
99+
100+
if s.isLimitExceeded() {
101+
t.Errorf("Limit should not have been exceeded yet")
102+
}
103+
104+
// Add one more item to exceed the limit
105+
s.set("target4", true)
106+
107+
// Ensure limitExceeded is true after exceeding the limit
108+
if !s.isLimitExceeded() {
109+
t.Errorf("Limit should be exceeded after adding target4")
110+
}
111+
112+
// Ensure that new items are not added once the limit is exceeded
113+
s.set("target5", true)
114+
if _, exists := s.get("target5"); exists {
115+
t.Errorf("target5 should not have been added as the limit was exceeded")
116+
}
117+
118+
// Clear the map and ensure limit is reset
119+
s.clear()
120+
121+
if s.isLimitExceeded() {
122+
t.Errorf("Limit should have been reset after clearing the map")
123+
}
124+
125+
// Add items again after clearing
126+
s.set("target6", true)
127+
if _, exists := s.get("target6"); !exists {
128+
t.Errorf("target6 should have been added after clearing the map")
129+
}
130+
131+
// Concurrency test
132+
t.Run("ConcurrencyTest", func(t *testing.T) {
133+
var wg sync.WaitGroup
134+
concurrencyLevel := 100
135+
136+
// Re-initialize the map for concurrency testing
137+
s = newSafeSeenTargets(100).(SafeSeenTargetsCache[string, bool])
138+
139+
// Concurrently set keys
140+
for i := 0; i < concurrencyLevel; i++ {
141+
wg.Add(1)
142+
go func(i int) {
143+
defer wg.Done()
144+
key := "target" + fmt.Sprint(i)
145+
s.set(key, true)
146+
}(i)
147+
}
148+
149+
// Concurrently get keys
150+
for i := 0; i < concurrencyLevel; i++ {
151+
wg.Add(1)
152+
go func(i int) {
153+
defer wg.Done()
154+
key := "target" + fmt.Sprint(i)
155+
s.get(key)
156+
}(i)
157+
}
158+
159+
// Concurrently clear the map
160+
for i := 0; i < concurrencyLevel/2; i++ {
161+
wg.Add(1)
162+
go func() {
163+
defer wg.Done()
164+
s.clear()
165+
}()
166+
}
167+
168+
wg.Wait()
169+
170+
// Ensure the map is cleared after the concurrency operations
171+
if s.size() > 0 {
172+
t.Errorf("Map size should be 0 after clearing, got %d", s.size())
173+
}
174+
})
175+
93176
}

analyticsservice/safe_seen_targets_map.go

+18-3
Original file line numberDiff line numberDiff line change
@@ -2,22 +2,32 @@ package analyticsservice
22

33
import (
44
"sync"
5+
"sync/atomic"
56
)
67

78
type safeSeenTargets struct {
89
sync.RWMutex
9-
data map[string]bool
10+
data map[string]bool
11+
maxSize int
12+
limitExceeded atomic.Bool
1013
}
1114

12-
func newSafeSeenTargets() SafeAnalyticsCache[string, bool] {
15+
func newSafeSeenTargets(maxSize int) SafeSeenTargetsCache[string, bool] {
1316
return &safeSeenTargets{
14-
data: make(map[string]bool),
17+
data: make(map[string]bool),
18+
maxSize: maxSize,
1519
}
1620
}
1721

1822
func (s *safeSeenTargets) set(key string, seen bool) {
1923
s.Lock()
2024
defer s.Unlock()
25+
26+
if len(s.data) >= s.maxSize {
27+
s.limitExceeded.Store(true)
28+
return
29+
}
30+
2131
s.data[key] = seen
2232
}
2333

@@ -44,6 +54,7 @@ func (s *safeSeenTargets) clear() {
4454
s.Lock()
4555
defer s.Unlock()
4656
s.data = make(map[string]bool)
57+
s.limitExceeded.Store(false)
4758
}
4859

4960
func (s *safeSeenTargets) iterate(f func(string, bool)) {
@@ -53,3 +64,7 @@ func (s *safeSeenTargets) iterate(f func(string, bool)) {
5364
f(key, value)
5465
}
5566
}
67+
68+
func (s *safeSeenTargets) isLimitExceeded() bool {
69+
return s.limitExceeded.Load()
70+
}

client/client.go

+1-1
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ func NewCfClient(sdkKey string, options ...ConfigOption) (*CfClient, error) {
7979
opt(config)
8080
}
8181

82-
analyticsService := analyticsservice.NewAnalyticsService(time.Minute, config.Logger)
82+
analyticsService := analyticsservice.NewAnalyticsService(time.Minute, config.Logger, config.seenTargetsMaxSize, config.seenTargetsClearInterval)
8383

8484
client := &CfClient{
8585
sdkKey: sdkKey,

0 commit comments

Comments
 (0)