Skip to content

Commit 4f4a14a

Browse files
committed
FFM-11660 Add size and clearing interval to safe seen targets
FFM-11660 Update tests FFM-11660 Make seen targets max size and clearing schedule configurable with defaults FFM-11660 Change level of bucketby missing log to debug FFM-11660 Add size and clearing interval to safe seen targets
1 parent f2362b4 commit 4f4a14a

File tree

8 files changed

+203
-52
lines changed

8 files changed

+203
-52
lines changed

analyticsservice/analytics.go

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,13 @@ 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+
setWithLimit(key K, value V)
53+
isLimitExceeded() bool
54+
}
55+
4956
type analyticsEvent struct {
5057
target *evaluation.Target
5158
featureConfig *rest.FeatureConfig
@@ -68,7 +75,7 @@ type AnalyticsService struct {
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
@@ -79,7 +86,7 @@ func NewAnalyticsService(timeout time.Duration, logger logger.Logger) *Analytics
7986
analyticsChan: make(chan analyticsEvent),
8087
evaluationAnalytics: newSafeEvaluationAnalytics(),
8188
targetAnalytics: newSafeTargetAnalytics(),
82-
seenTargets: newSafeSeenTargets(),
89+
seenTargets: newSafeSeenTargets(seenTargetsMaxSize, seenTargetsClearingSchedule),
8390
timeout: serviceTimeout,
8491
logger: logger,
8592
}

analyticsservice/analytics_test.go

Lines changed: 1 addition & 1 deletion
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

Lines changed: 85 additions & 3 deletions
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,94 @@ 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, 0).(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.setWithLimit("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.setWithLimit("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.setWithLimit("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, 0).(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.setWithLimit(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+
})
93175
}

analyticsservice/safe_seen_targets_map.go

Lines changed: 44 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,25 +2,59 @@ package analyticsservice
22

33
import (
44
"sync"
5+
"sync/atomic"
6+
"time"
57
)
68

79
type safeSeenTargets struct {
810
sync.RWMutex
9-
data map[string]bool
11+
data map[string]bool
12+
maxSize int
13+
limitExceeded atomic.Bool
14+
clearingTimer *time.Ticker
1015
}
1116

12-
func newSafeSeenTargets() SafeAnalyticsCache[string, bool] {
13-
return &safeSeenTargets{
14-
data: make(map[string]bool),
17+
// Implements SafeSeenTargetsCache
18+
func newSafeSeenTargets(maxSize int, clearingInterval time.Duration) SafeSeenTargetsCache[string, bool] {
19+
st := &safeSeenTargets{
20+
data: make(map[string]bool),
21+
maxSize: maxSize,
1522
}
23+
24+
if clearingInterval > 0 {
25+
st.clearingTimer = time.NewTicker(clearingInterval)
26+
27+
// Start a goroutine to clear the cache at a set interval
28+
go func() {
29+
for range st.clearingTimer.C {
30+
st.clear()
31+
}
32+
}()
33+
}
34+
35+
return st
1636
}
1737

18-
func (s *safeSeenTargets) set(key string, seen bool) {
38+
func (s *safeSeenTargets) setWithLimit(key string, seen bool) {
39+
if s.limitExceeded.Load() {
40+
return
41+
}
42+
1943
s.Lock()
2044
defer s.Unlock()
45+
46+
if len(s.data) >= s.maxSize {
47+
s.limitExceeded.Store(true)
48+
}
49+
2150
s.data[key] = seen
2251
}
2352

53+
// The regular set method just calls SetWithLimit
54+
func (s *safeSeenTargets) set(key string, seen bool) {
55+
s.setWithLimit(key, seen)
56+
}
57+
2458
func (s *safeSeenTargets) get(key string) (bool, bool) {
2559
s.RLock()
2660
defer s.RUnlock()
@@ -44,6 +78,7 @@ func (s *safeSeenTargets) clear() {
4478
s.Lock()
4579
defer s.Unlock()
4680
s.data = make(map[string]bool)
81+
s.limitExceeded.Store(false)
4782
}
4883

4984
func (s *safeSeenTargets) iterate(f func(string, bool)) {
@@ -53,3 +88,7 @@ func (s *safeSeenTargets) iterate(f func(string, bool)) {
5388
f(key, value)
5489
}
5590
}
91+
92+
func (s *safeSeenTargets) isLimitExceeded() bool {
93+
return s.limitExceeded.Load()
94+
}

client/client.go

Lines changed: 1 addition & 1 deletion
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,

client/config.go

Lines changed: 41 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -18,26 +18,28 @@ import (
1818
)
1919

2020
type config struct {
21-
url string
22-
eventsURL string
23-
pullInterval uint // in seconds
24-
Cache cache.Cache
25-
Store storage.Storage
26-
Logger logger.Logger
27-
httpClient *http.Client
28-
authHttpClient *http.Client
29-
enableStream bool
30-
enableStore bool
31-
target evaluation.Target
32-
eventStreamListener stream.EventStreamListener
33-
enableAnalytics bool
34-
proxyMode bool
35-
waitForInitialized bool
36-
maxAuthRetries int
37-
authRetryStrategy *backoff.ExponentialBackOff
38-
streamingRetryStrategy *backoff.ExponentialBackOff
39-
sleeper types.Sleeper
40-
apiConfig *apiConfiguration
21+
url string
22+
eventsURL string
23+
pullInterval uint // in seconds
24+
Cache cache.Cache
25+
Store storage.Storage
26+
Logger logger.Logger
27+
httpClient *http.Client
28+
authHttpClient *http.Client
29+
enableStream bool
30+
enableStore bool
31+
target evaluation.Target
32+
eventStreamListener stream.EventStreamListener
33+
enableAnalytics bool
34+
proxyMode bool
35+
waitForInitialized bool
36+
maxAuthRetries int
37+
authRetryStrategy *backoff.ExponentialBackOff
38+
streamingRetryStrategy *backoff.ExponentialBackOff
39+
sleeper types.Sleeper
40+
apiConfig *apiConfiguration
41+
seenTargetsMaxSize int
42+
seenTargetsClearInterval time.Duration
4143
}
4244

4345
type apiConfiguration struct {
@@ -87,24 +89,25 @@ func newDefaultConfig(log logger.Logger) *config {
8789
}
8890

8991
return &config{
90-
url: "https://config.ff.harness.io/api/1.0",
91-
eventsURL: "https://events.ff.harness.io/api/1.0",
92-
pullInterval: 60,
93-
Cache: defaultCache,
94-
Store: defaultStore,
95-
Logger: log,
96-
authHttpClient: authHttpClient,
97-
httpClient: requestHttpClient.StandardClient(),
98-
enableStream: true,
99-
enableStore: true,
100-
enableAnalytics: true,
101-
proxyMode: false,
102-
// Indicate that we should retry forever by default
103-
maxAuthRetries: -1,
104-
authRetryStrategy: getDefaultExpBackoff(),
105-
streamingRetryStrategy: getDefaultExpBackoff(),
106-
sleeper: &types.RealClock{},
107-
apiConfig: apiConfig,
92+
url: "https://config.ff.harness.io/api/1.0",
93+
eventsURL: "https://events.ff.harness.io/api/1.0",
94+
pullInterval: 60,
95+
Cache: defaultCache,
96+
Store: defaultStore,
97+
Logger: log,
98+
authHttpClient: authHttpClient,
99+
httpClient: requestHttpClient.StandardClient(),
100+
enableStream: true,
101+
enableStore: true,
102+
enableAnalytics: true,
103+
proxyMode: false,
104+
maxAuthRetries: -1, // Indicate that we should retry forever by default
105+
authRetryStrategy: getDefaultExpBackoff(),
106+
streamingRetryStrategy: getDefaultExpBackoff(),
107+
sleeper: &types.RealClock{},
108+
apiConfig: apiConfig,
109+
seenTargetsMaxSize: 500000,
110+
seenTargetsClearInterval: 24 * time.Hour,
108111
}
109112
}
110113

client/options.go

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
11
package client
22

33
import (
4+
"net/http"
5+
"time"
6+
47
"github.com/cenkalti/backoff/v4"
58
"github.com/harness/ff-golang-server-sdk/cache"
69
"github.com/harness/ff-golang-server-sdk/evaluation"
710
"github.com/harness/ff-golang-server-sdk/logger"
811
"github.com/harness/ff-golang-server-sdk/storage"
912
"github.com/harness/ff-golang-server-sdk/stream"
1013
"github.com/harness/ff-golang-server-sdk/types"
11-
"net/http"
1214
)
1315

1416
// ConfigOption is used as return value for advanced client configuration
@@ -142,3 +144,21 @@ func WithSleeper(sleeper types.Sleeper) ConfigOption {
142144
config.sleeper = sleeper
143145
}
144146
}
147+
148+
// WithSeenTargetsMaxSize sets the maximum size for the seen targets map.
149+
// The SeenTargetsCache helps to reduce the size of the analytics payload that the SDK sends to the Feature Flags Service.
150+
// This method allows you to set the maximum number of unique targets that will be stored in the SeenTargets cache.
151+
// By default, the limit is set to 500,000 unique targets. You can increase this number if you need to handle more than
152+
// 500,000 targets, which will reduce the payload size but will also increase memory usage.
153+
func WithSeenTargetsMaxSize(maxSize int) ConfigOption {
154+
return func(config *config) {
155+
config.seenTargetsMaxSize = maxSize
156+
}
157+
}
158+
159+
// WithSeenTargetsClearInterval sets the clearing interval for the seen targets map.
160+
func WithSeenTargetsClearInterval(interval time.Duration) ConfigOption {
161+
return func(config *config) {
162+
config.seenTargetsClearInterval = interval
163+
}
164+
}

evaluation/util.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ func isEnabled(target *Target, bucketBy string, percentage int) bool {
8080
if value == "" {
8181
return false
8282
}
83-
log.Warnf("%s BucketBy attribute not found in target attributes, falling back to 'identifier': missing=%s, using value=%s", sdk_codes.MissingBucketBy, bucketBy, value)
83+
log.Debugf("%s BucketBy attribute not found in target attributes, falling back to 'identifier': missing=%s, using value=%s", sdk_codes.MissingBucketBy, bucketBy, value)
8484
bucketBy = "identifier"
8585
}
8686

0 commit comments

Comments
 (0)