Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion eppoclient/bandits_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ func Test_InferContextAttributes(t *testing.T) {
func Test_bandits_sdkTestData(t *testing.T) {
flags := readJsonFile[configResponse]("test-data/ufc/bandit-flags-v1.json")
bandits := readJsonFile[banditResponse]("test-data/ufc/bandit-models-v1.json")
configStore := newConfigurationStore(configuration{
configStore := newConfigurationStoreWithConfig(configuration{
flags: flags,
bandits: bandits,
})
Expand Down
14 changes: 14 additions & 0 deletions eppoclient/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,20 @@ func newEppoClient(
}
}

// Returns a channel that gets closed after client has been
// *successfully* initialized.
//
// It is recommended to apply a timeout to initialization as otherwise
// it may hang up indefinitely.
//
// select {
// case <-client.Initialized():
// case <-time.After(5 * time.Second):
// }
func (ec *EppoClient) Initialized() <-chan struct{} {
return ec.configurationStore.Initialized()
}

func (ec *EppoClient) GetBoolAssignment(flagKey string, subjectKey string, subjectAttributes Attributes, defaultValue bool) (bool, error) {
variation, err := ec.getAssignment(ec.configurationStore.getConfiguration(), flagKey, subjectKey, subjectAttributes, booleanVariation)
if err != nil || variation == nil {
Expand Down
51 changes: 44 additions & 7 deletions eppoclient/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package eppoclient

import (
"testing"
"time"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
Expand All @@ -15,15 +16,15 @@ var (

func Test_AssignBlankExperiment(t *testing.T) {
var mockLogger = new(mockLogger)
client := newEppoClient(newConfigurationStore(configuration{}), nil, nil, mockLogger, applicationLogger)
client := newEppoClient(newConfigurationStore(), nil, nil, mockLogger, applicationLogger)

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

func Test_AssignBlankSubject(t *testing.T) {
var mockLogger = new(mockLogger)
client := newEppoClient(newConfigurationStore(configuration{}), nil, nil, mockLogger, applicationLogger)
client := newEppoClient(newConfigurationStore(), nil, nil, mockLogger, applicationLogger)

_, err := client.GetStringAssignment("experiment-1", "", Attributes{}, "")
assert.Error(t, err)
Expand Down Expand Up @@ -96,7 +97,7 @@ func Test_LogAssignment(t *testing.T) {
},
}

client := newEppoClient(newConfigurationStore(configuration{flags: config}), nil, nil, mockLogger, applicationLogger)
client := newEppoClient(newConfigurationStoreWithConfig(configuration{flags: config}), nil, nil, mockLogger, applicationLogger)

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

client := newEppoClient(newConfigurationStore(configuration{flags: flags, bandits: bandits}), nil, nil, logger, applicationLogger)
client := newEppoClient(newConfigurationStoreWithConfig(configuration{flags: flags, bandits: bandits}), nil, nil, logger, applicationLogger)
actions := map[string]ContextAttributes{
"action1": {},
}
Expand Down Expand Up @@ -194,7 +195,7 @@ func Test_GetStringAssignmentHandlesLoggingPanic(t *testing.T) {
},
}}

client := newEppoClient(newConfigurationStore(configuration{flags: config}), nil, nil, mockLogger, applicationLogger)
client := newEppoClient(newConfigurationStoreWithConfig(configuration{flags: config}), nil, nil, mockLogger, applicationLogger)

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

client := newEppoClient(newConfigurationStore(configuration{flags: flags, bandits: bandits}), nil, nil, logger, applicationLogger)
client := newEppoClient(newConfigurationStoreWithConfig(configuration{flags: flags, bandits: bandits}), nil, nil, logger, applicationLogger)
actions := map[string]ContextAttributes{
"action1": {},
}
Expand Down Expand Up @@ -278,7 +279,7 @@ func Test_client_correctActionIsReturnedIfBanditLoggerPanics(t *testing.T) {
},
}

client := newEppoClient(newConfigurationStore(configuration{flags: flags, bandits: bandits}), nil, nil, logger, applicationLogger)
client := newEppoClient(newConfigurationStoreWithConfig(configuration{flags: flags, bandits: bandits}), nil, nil, logger, applicationLogger)
actions := map[string]ContextAttributes{
"action1": {},
}
Expand All @@ -290,3 +291,39 @@ func Test_client_correctActionIsReturnedIfBanditLoggerPanics(t *testing.T) {
Action: &expectedAction,
}, result)
}

func Test_Initialized_timeout(t *testing.T) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🙌

var mockLogger = new(mockLogger)
client := newEppoClient(newConfigurationStore(), nil, nil, mockLogger, applicationLogger)

timedOut := false
select {
case <-client.Initialized():
timedOut = false
case <-time.After(1 * time.Millisecond):
timedOut = true
Comment on lines +301 to +304
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

According to ChatGPT, this select block will wait for one of these two channels to complete, similar to JavaScript's Promise.race() 👌

}

assert.True(t, timedOut)
}

func Test_Initialized_success(t *testing.T) {
var mockLogger = new(mockLogger)
configurationStore := newConfigurationStore()
client := newEppoClient(configurationStore, nil, nil, mockLogger, applicationLogger)

go func() {
<-time.After(1 * time.Microsecond)
configurationStore.setConfiguration(configuration{})
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

}()

timedOut := false
select {
case <-client.Initialized():
timedOut = false
case <-time.After(1 * time.Millisecond):
timedOut = true
}

assert.False(t, timedOut)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

}
57 changes: 57 additions & 0 deletions eppoclient/configuration.go
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This file was extracted from configurationstore.go without any changes

Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package eppoclient

type configuration struct {
flags configResponse
bandits banditResponse
// flag key -> variation value -> banditVariation.
//
// This is cached from `flags` field for easier access in
// evaluation.
banditFlagAssociations map[string]map[string]banditVariation
}

func (c *configuration) precompute() {
associations := make(map[string]map[string]banditVariation)

c.flags.precompute()

for _, banditVariations := range c.flags.Bandits {
for _, bandit := range banditVariations {
byVariation, ok := associations[bandit.FlagKey]
if !ok {
byVariation = make(map[string]banditVariation)
associations[bandit.FlagKey] = byVariation
}
byVariation[bandit.VariationValue] = bandit
}
}

c.banditFlagAssociations = associations
}

func (c configuration) getBanditVariant(flagKey, variation string) (result banditVariation, ok bool) {
byVariation, ok := c.banditFlagAssociations[flagKey]
if !ok {
return result, false
}
result, ok = byVariation[variation]
return result, ok
}

func (c configuration) getFlagConfiguration(key string) (*flagConfiguration, error) {
flag, ok := c.flags.Flags[key]
if !ok {
return nil, ErrFlagConfigurationNotFound
}

return flag, nil
}

func (c configuration) getBanditConfiguration(key string) (banditConfiguration, error) {
bandit, ok := c.bandits.Bandits[key]
if !ok {
return bandit, ErrBanditConfigurationNotFound
}

return bandit, nil
}
4 changes: 2 additions & 2 deletions eppoclient/configurationrequestor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ func Test_configurationRequestor_requestBandits(t *testing.T) {

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

configurationRequestor.FetchAndStoreConfigurations()
Expand All @@ -36,7 +36,7 @@ func Test_configurationRequestor_shouldNotRequestBanditsIfNotPresentInFlags(t *t

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

configurationRequestor.FetchAndStoreConfigurations()
Expand Down
95 changes: 36 additions & 59 deletions eppoclient/configurationstore.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,82 +4,59 @@ import (
"sync/atomic"
)

type configuration struct {
flags configResponse
bandits banditResponse
// flag key -> variation value -> banditVariation.
//
// This is cached from `flags` field for easier access in
// evaluation.
banditFlagAssociations map[string]map[string]banditVariation
}

func (c *configuration) precompute() {
associations := make(map[string]map[string]banditVariation)

c.flags.precompute()

for _, banditVariations := range c.flags.Bandits {
for _, bandit := range banditVariations {
byVariation, ok := associations[bandit.FlagKey]
if !ok {
byVariation = make(map[string]banditVariation)
associations[bandit.FlagKey] = byVariation
}
byVariation[bandit.VariationValue] = bandit
}
}

c.banditFlagAssociations = associations
}

func (c configuration) getBanditVariant(flagKey, variation string) (result banditVariation, ok bool) {
byVariation, ok := c.banditFlagAssociations[flagKey]
if !ok {
return result, false
}
result, ok = byVariation[variation]
return result, ok
}

func (c configuration) getFlagConfiguration(key string) (*flagConfiguration, error) {
flag, ok := c.flags.Flags[key]
if !ok {
return nil, ErrFlagConfigurationNotFound
}

return flag, nil
}

func (c configuration) getBanditConfiguration(key string) (banditConfiguration, error) {
bandit, ok := c.bandits.Bandits[key]
if !ok {
return bandit, ErrBanditConfigurationNotFound
}

return bandit, nil
}

// `configurationStore` is a thread-safe in-memory storage. It stores
// the currently active configuration and provides access to multiple
// readers (e.g., flag/bandit evaluation) and writers (e.g.,
// configuration requestor).
type configurationStore struct {
configuration atomic.Pointer[configuration]

// `initializedCh` is closed when we receive a proper
// configuration.
initializedCh chan struct{}
// `isInitialized` is used to protect `initializedCh`, so we
// don’t double-close it (which is an error in Go).
isInitialized atomic.Bool
}

func newConfigurationStore() *configurationStore {
return &configurationStore{
initializedCh: make(chan struct{}),
}
}

func newConfigurationStore(configuration configuration) *configurationStore {
store := &configurationStore{}
func newConfigurationStoreWithConfig(configuration configuration) *configurationStore {
store := newConfigurationStore()
store.setConfiguration(configuration)
return store
}

// Returns a snapshot of the currently active configuration.
func (cs *configurationStore) getConfiguration() configuration {
return *cs.configuration.Load()
if config := cs.configuration.Load(); config != nil {
return *config
} else {
return configuration{}
}
}

func (cs *configurationStore) setConfiguration(configuration configuration) {
configuration.precompute()
cs.configuration.Store(&configuration)
cs.setInitialized()
}

// Set `initialized` flag to `true` notifying anyone waiting on it.
func (cs *configurationStore) setInitialized() {
if cs.isInitialized.CompareAndSwap(false, true) {
// Channels can only be closed once, so we protect the
// call to `close` with a CAS.
close(cs.initializedCh)
}
}

// Returns a channel that gets closed after configuration store is
// successfully initialized.
func (cs *configurationStore) Initialized() <-chan struct{} {
return cs.initializedCh
}
4 changes: 2 additions & 2 deletions eppoclient/configurationstore_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import (
)

func Test_GetConfiguration_unknownKey(t *testing.T) {
var store = newConfigurationStore(configuration{})
store := newConfigurationStore()

config := store.getConfiguration()
result, err := config.getFlagConfiguration("unknown_exp")
Expand All @@ -26,7 +26,7 @@ func Test_GetConfiguration_knownKey(t *testing.T) {
},
},
}
var store = newConfigurationStore(configuration{flags: flags})
store := newConfigurationStoreWithConfig(configuration{flags: flags})

config := store.getConfiguration()
result, err := config.getFlagConfiguration("experiment-key-1")
Expand Down
2 changes: 1 addition & 1 deletion eppoclient/initclient.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ func InitClient(config Config) (*EppoClient, error) {
applicationLogger := config.ApplicationLogger

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

assignmentLogger := config.AssignmentLogger
Expand Down
2 changes: 1 addition & 1 deletion eppoclient/version.go
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
package eppoclient

var __version__ = "6.0.0"
var __version__ = "6.1.0"
Loading