Skip to content

Commit acb0960

Browse files
authored
feat: add EppoClient.Initialized() method to wait for initialization (#72)
1 parent 1ce7f11 commit acb0960

9 files changed

+158
-73
lines changed

eppoclient/bandits_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ func Test_InferContextAttributes(t *testing.T) {
5454
func Test_bandits_sdkTestData(t *testing.T) {
5555
flags := readJsonFile[configResponse]("test-data/ufc/bandit-flags-v1.json")
5656
bandits := readJsonFile[banditResponse]("test-data/ufc/bandit-models-v1.json")
57-
configStore := newConfigurationStore(configuration{
57+
configStore := newConfigurationStoreWithConfig(configuration{
5858
flags: flags,
5959
bandits: bandits,
6060
})

eppoclient/client.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,20 @@ func newEppoClient(
3333
}
3434
}
3535

36+
// Returns a channel that gets closed after client has been
37+
// *successfully* initialized.
38+
//
39+
// It is recommended to apply a timeout to initialization as otherwise
40+
// it may hang up indefinitely.
41+
//
42+
// select {
43+
// case <-client.Initialized():
44+
// case <-time.After(5 * time.Second):
45+
// }
46+
func (ec *EppoClient) Initialized() <-chan struct{} {
47+
return ec.configurationStore.Initialized()
48+
}
49+
3650
func (ec *EppoClient) GetBoolAssignment(flagKey string, subjectKey string, subjectAttributes Attributes, defaultValue bool) (bool, error) {
3751
variation, err := ec.getAssignment(ec.configurationStore.getConfiguration(), flagKey, subjectKey, subjectAttributes, booleanVariation)
3852
if err != nil || variation == nil {

eppoclient/client_test.go

Lines changed: 44 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package eppoclient
22

33
import (
44
"testing"
5+
"time"
56

67
"github.com/stretchr/testify/assert"
78
"github.com/stretchr/testify/mock"
@@ -15,15 +16,15 @@ var (
1516

1617
func Test_AssignBlankExperiment(t *testing.T) {
1718
var mockLogger = new(mockLogger)
18-
client := newEppoClient(newConfigurationStore(configuration{}), nil, nil, mockLogger, applicationLogger)
19+
client := newEppoClient(newConfigurationStore(), nil, nil, mockLogger, applicationLogger)
1920

2021
_, err := client.GetStringAssignment("", "subject-1", Attributes{}, "")
2122
assert.Error(t, err)
2223
}
2324

2425
func Test_AssignBlankSubject(t *testing.T) {
2526
var mockLogger = new(mockLogger)
26-
client := newEppoClient(newConfigurationStore(configuration{}), nil, nil, mockLogger, applicationLogger)
27+
client := newEppoClient(newConfigurationStore(), nil, nil, mockLogger, applicationLogger)
2728

2829
_, err := client.GetStringAssignment("experiment-1", "", Attributes{}, "")
2930
assert.Error(t, err)
@@ -96,7 +97,7 @@ func Test_LogAssignment(t *testing.T) {
9697
},
9798
}
9899

99-
client := newEppoClient(newConfigurationStore(configuration{flags: config}), nil, nil, mockLogger, applicationLogger)
100+
client := newEppoClient(newConfigurationStoreWithConfig(configuration{flags: config}), nil, nil, mockLogger, applicationLogger)
100101

101102
assignment, err := client.GetStringAssignment("experiment-key-1", "user-1", Attributes{}, "")
102103
expected := "control"
@@ -141,7 +142,7 @@ func Test_client_loggerIsCalledWithProperBanditEvent(t *testing.T) {
141142
},
142143
}
143144

144-
client := newEppoClient(newConfigurationStore(configuration{flags: flags, bandits: bandits}), nil, nil, logger, applicationLogger)
145+
client := newEppoClient(newConfigurationStoreWithConfig(configuration{flags: flags, bandits: bandits}), nil, nil, logger, applicationLogger)
145146
actions := map[string]ContextAttributes{
146147
"action1": {},
147148
}
@@ -194,7 +195,7 @@ func Test_GetStringAssignmentHandlesLoggingPanic(t *testing.T) {
194195
},
195196
}}
196197

197-
client := newEppoClient(newConfigurationStore(configuration{flags: config}), nil, nil, mockLogger, applicationLogger)
198+
client := newEppoClient(newConfigurationStoreWithConfig(configuration{flags: config}), nil, nil, mockLogger, applicationLogger)
198199

199200
assignment, err := client.GetStringAssignment("experiment-key-1", "user-1", Attributes{}, "")
200201
expected := "control"
@@ -236,7 +237,7 @@ func Test_client_handlesBanditLoggerPanic(t *testing.T) {
236237
},
237238
}
238239

239-
client := newEppoClient(newConfigurationStore(configuration{flags: flags, bandits: bandits}), nil, nil, logger, applicationLogger)
240+
client := newEppoClient(newConfigurationStoreWithConfig(configuration{flags: flags, bandits: bandits}), nil, nil, logger, applicationLogger)
240241
actions := map[string]ContextAttributes{
241242
"action1": {},
242243
}
@@ -278,7 +279,7 @@ func Test_client_correctActionIsReturnedIfBanditLoggerPanics(t *testing.T) {
278279
},
279280
}
280281

281-
client := newEppoClient(newConfigurationStore(configuration{flags: flags, bandits: bandits}), nil, nil, logger, applicationLogger)
282+
client := newEppoClient(newConfigurationStoreWithConfig(configuration{flags: flags, bandits: bandits}), nil, nil, logger, applicationLogger)
282283
actions := map[string]ContextAttributes{
283284
"action1": {},
284285
}
@@ -290,3 +291,39 @@ func Test_client_correctActionIsReturnedIfBanditLoggerPanics(t *testing.T) {
290291
Action: &expectedAction,
291292
}, result)
292293
}
294+
295+
func Test_Initialized_timeout(t *testing.T) {
296+
var mockLogger = new(mockLogger)
297+
client := newEppoClient(newConfigurationStore(), nil, nil, mockLogger, applicationLogger)
298+
299+
timedOut := false
300+
select {
301+
case <-client.Initialized():
302+
timedOut = false
303+
case <-time.After(1 * time.Millisecond):
304+
timedOut = true
305+
}
306+
307+
assert.True(t, timedOut)
308+
}
309+
310+
func Test_Initialized_success(t *testing.T) {
311+
var mockLogger = new(mockLogger)
312+
configurationStore := newConfigurationStore()
313+
client := newEppoClient(configurationStore, nil, nil, mockLogger, applicationLogger)
314+
315+
go func() {
316+
<-time.After(1 * time.Microsecond)
317+
configurationStore.setConfiguration(configuration{})
318+
}()
319+
320+
timedOut := false
321+
select {
322+
case <-client.Initialized():
323+
timedOut = false
324+
case <-time.After(1 * time.Millisecond):
325+
timedOut = true
326+
}
327+
328+
assert.False(t, timedOut)
329+
}

eppoclient/configuration.go

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
package eppoclient
2+
3+
type configuration struct {
4+
flags configResponse
5+
bandits banditResponse
6+
// flag key -> variation value -> banditVariation.
7+
//
8+
// This is cached from `flags` field for easier access in
9+
// evaluation.
10+
banditFlagAssociations map[string]map[string]banditVariation
11+
}
12+
13+
func (c *configuration) precompute() {
14+
associations := make(map[string]map[string]banditVariation)
15+
16+
c.flags.precompute()
17+
18+
for _, banditVariations := range c.flags.Bandits {
19+
for _, bandit := range banditVariations {
20+
byVariation, ok := associations[bandit.FlagKey]
21+
if !ok {
22+
byVariation = make(map[string]banditVariation)
23+
associations[bandit.FlagKey] = byVariation
24+
}
25+
byVariation[bandit.VariationValue] = bandit
26+
}
27+
}
28+
29+
c.banditFlagAssociations = associations
30+
}
31+
32+
func (c configuration) getBanditVariant(flagKey, variation string) (result banditVariation, ok bool) {
33+
byVariation, ok := c.banditFlagAssociations[flagKey]
34+
if !ok {
35+
return result, false
36+
}
37+
result, ok = byVariation[variation]
38+
return result, ok
39+
}
40+
41+
func (c configuration) getFlagConfiguration(key string) (*flagConfiguration, error) {
42+
flag, ok := c.flags.Flags[key]
43+
if !ok {
44+
return nil, ErrFlagConfigurationNotFound
45+
}
46+
47+
return flag, nil
48+
}
49+
50+
func (c configuration) getBanditConfiguration(key string) (banditConfiguration, error) {
51+
bandit, ok := c.bandits.Bandits[key]
52+
if !ok {
53+
return bandit, ErrBanditConfigurationNotFound
54+
}
55+
56+
return bandit, nil
57+
}

eppoclient/configurationrequestor_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ func Test_configurationRequestor_requestBandits(t *testing.T) {
1717

1818
sdkParams := SDKParams{sdkKey: "blah", sdkName: "go", sdkVersion: __version__}
1919
httpClient := newHttpClient(server.URL, &http.Client{Timeout: REQUEST_TIMEOUT_SECONDS}, sdkParams)
20-
configurationStore := newConfigurationStore(configuration{})
20+
configurationStore := newConfigurationStore()
2121
configurationRequestor := newConfigurationRequestor(*httpClient, configurationStore, applicationLogger)
2222

2323
configurationRequestor.FetchAndStoreConfigurations()
@@ -36,7 +36,7 @@ func Test_configurationRequestor_shouldNotRequestBanditsIfNotPresentInFlags(t *t
3636

3737
sdkParams := SDKParams{sdkKey: "blah", sdkName: "go", sdkVersion: __version__}
3838
httpClient := newHttpClient(server.URL, &http.Client{Timeout: REQUEST_TIMEOUT_SECONDS}, sdkParams)
39-
configurationStore := newConfigurationStore(configuration{})
39+
configurationStore := newConfigurationStore()
4040
configurationRequestor := newConfigurationRequestor(*httpClient, configurationStore, applicationLogger)
4141

4242
configurationRequestor.FetchAndStoreConfigurations()

eppoclient/configurationstore.go

Lines changed: 36 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -4,82 +4,59 @@ import (
44
"sync/atomic"
55
)
66

7-
type configuration struct {
8-
flags configResponse
9-
bandits banditResponse
10-
// flag key -> variation value -> banditVariation.
11-
//
12-
// This is cached from `flags` field for easier access in
13-
// evaluation.
14-
banditFlagAssociations map[string]map[string]banditVariation
15-
}
16-
17-
func (c *configuration) precompute() {
18-
associations := make(map[string]map[string]banditVariation)
19-
20-
c.flags.precompute()
21-
22-
for _, banditVariations := range c.flags.Bandits {
23-
for _, bandit := range banditVariations {
24-
byVariation, ok := associations[bandit.FlagKey]
25-
if !ok {
26-
byVariation = make(map[string]banditVariation)
27-
associations[bandit.FlagKey] = byVariation
28-
}
29-
byVariation[bandit.VariationValue] = bandit
30-
}
31-
}
32-
33-
c.banditFlagAssociations = associations
34-
}
35-
36-
func (c configuration) getBanditVariant(flagKey, variation string) (result banditVariation, ok bool) {
37-
byVariation, ok := c.banditFlagAssociations[flagKey]
38-
if !ok {
39-
return result, false
40-
}
41-
result, ok = byVariation[variation]
42-
return result, ok
43-
}
44-
45-
func (c configuration) getFlagConfiguration(key string) (*flagConfiguration, error) {
46-
flag, ok := c.flags.Flags[key]
47-
if !ok {
48-
return nil, ErrFlagConfigurationNotFound
49-
}
50-
51-
return flag, nil
52-
}
53-
54-
func (c configuration) getBanditConfiguration(key string) (banditConfiguration, error) {
55-
bandit, ok := c.bandits.Bandits[key]
56-
if !ok {
57-
return bandit, ErrBanditConfigurationNotFound
58-
}
59-
60-
return bandit, nil
61-
}
62-
637
// `configurationStore` is a thread-safe in-memory storage. It stores
648
// the currently active configuration and provides access to multiple
659
// readers (e.g., flag/bandit evaluation) and writers (e.g.,
6610
// configuration requestor).
6711
type configurationStore struct {
6812
configuration atomic.Pointer[configuration]
13+
14+
// `initializedCh` is closed when we receive a proper
15+
// configuration.
16+
initializedCh chan struct{}
17+
// `isInitialized` is used to protect `initializedCh`, so we
18+
// don’t double-close it (which is an error in Go).
19+
isInitialized atomic.Bool
20+
}
21+
22+
func newConfigurationStore() *configurationStore {
23+
return &configurationStore{
24+
initializedCh: make(chan struct{}),
25+
}
6926
}
7027

71-
func newConfigurationStore(configuration configuration) *configurationStore {
72-
store := &configurationStore{}
28+
func newConfigurationStoreWithConfig(configuration configuration) *configurationStore {
29+
store := newConfigurationStore()
7330
store.setConfiguration(configuration)
7431
return store
7532
}
7633

7734
// Returns a snapshot of the currently active configuration.
7835
func (cs *configurationStore) getConfiguration() configuration {
79-
return *cs.configuration.Load()
36+
if config := cs.configuration.Load(); config != nil {
37+
return *config
38+
} else {
39+
return configuration{}
40+
}
8041
}
8142

8243
func (cs *configurationStore) setConfiguration(configuration configuration) {
8344
configuration.precompute()
8445
cs.configuration.Store(&configuration)
46+
cs.setInitialized()
47+
}
48+
49+
// Set `initialized` flag to `true` notifying anyone waiting on it.
50+
func (cs *configurationStore) setInitialized() {
51+
if cs.isInitialized.CompareAndSwap(false, true) {
52+
// Channels can only be closed once, so we protect the
53+
// call to `close` with a CAS.
54+
close(cs.initializedCh)
55+
}
56+
}
57+
58+
// Returns a channel that gets closed after configuration store is
59+
// successfully initialized.
60+
func (cs *configurationStore) Initialized() <-chan struct{} {
61+
return cs.initializedCh
8562
}

eppoclient/configurationstore_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import (
77
)
88

99
func Test_GetConfiguration_unknownKey(t *testing.T) {
10-
var store = newConfigurationStore(configuration{})
10+
store := newConfigurationStore()
1111

1212
config := store.getConfiguration()
1313
result, err := config.getFlagConfiguration("unknown_exp")
@@ -26,7 +26,7 @@ func Test_GetConfiguration_knownKey(t *testing.T) {
2626
},
2727
},
2828
}
29-
var store = newConfigurationStore(configuration{flags: flags})
29+
store := newConfigurationStoreWithConfig(configuration{flags: flags})
3030

3131
config := store.getConfiguration()
3232
result, err := config.getFlagConfiguration("experiment-key-1")

eppoclient/initclient.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ func InitClient(config Config) (*EppoClient, error) {
1515
applicationLogger := config.ApplicationLogger
1616

1717
httpClient := newHttpClient(config.BaseUrl, &http.Client{Timeout: REQUEST_TIMEOUT_SECONDS}, sdkParams)
18-
configStore := newConfigurationStore(configuration{})
18+
configStore := newConfigurationStore()
1919
requestor := newConfigurationRequestor(*httpClient, configStore, applicationLogger)
2020

2121
assignmentLogger := config.AssignmentLogger

eppoclient/version.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
package eppoclient
22

3-
var __version__ = "6.0.0"
3+
var __version__ = "6.1.0"

0 commit comments

Comments
 (0)