diff --git a/eppoclient/bandits_test.go b/eppoclient/bandits_test.go index 51639f2..237e474 100644 --- a/eppoclient/bandits_test.go +++ b/eppoclient/bandits_test.go @@ -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, }) diff --git a/eppoclient/client.go b/eppoclient/client.go index 0dff4af..030ebe8 100644 --- a/eppoclient/client.go +++ b/eppoclient/client.go @@ -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 { diff --git a/eppoclient/client_test.go b/eppoclient/client_test.go index f4bd379..2391147 100644 --- a/eppoclient/client_test.go +++ b/eppoclient/client_test.go @@ -2,6 +2,7 @@ package eppoclient import ( "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" @@ -15,7 +16,7 @@ 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) @@ -23,7 +24,7 @@ func Test_AssignBlankExperiment(t *testing.T) { 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) @@ -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" @@ -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": {}, } @@ -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" @@ -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": {}, } @@ -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": {}, } @@ -290,3 +291,39 @@ func Test_client_correctActionIsReturnedIfBanditLoggerPanics(t *testing.T) { Action: &expectedAction, }, result) } + +func Test_Initialized_timeout(t *testing.T) { + 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 + } + + 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{}) + }() + + timedOut := false + select { + case <-client.Initialized(): + timedOut = false + case <-time.After(1 * time.Millisecond): + timedOut = true + } + + assert.False(t, timedOut) +} diff --git a/eppoclient/configuration.go b/eppoclient/configuration.go new file mode 100644 index 0000000..5f3fc41 --- /dev/null +++ b/eppoclient/configuration.go @@ -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 +} diff --git a/eppoclient/configurationrequestor_test.go b/eppoclient/configurationrequestor_test.go index 2402944..2595262 100644 --- a/eppoclient/configurationrequestor_test.go +++ b/eppoclient/configurationrequestor_test.go @@ -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() @@ -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() diff --git a/eppoclient/configurationstore.go b/eppoclient/configurationstore.go index 0d5cf38..827054f 100644 --- a/eppoclient/configurationstore.go +++ b/eppoclient/configurationstore.go @@ -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 } diff --git a/eppoclient/configurationstore_test.go b/eppoclient/configurationstore_test.go index af6f0c4..3bac38d 100644 --- a/eppoclient/configurationstore_test.go +++ b/eppoclient/configurationstore_test.go @@ -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") @@ -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") diff --git a/eppoclient/initclient.go b/eppoclient/initclient.go index 631445b..e89c9ac 100644 --- a/eppoclient/initclient.go +++ b/eppoclient/initclient.go @@ -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 diff --git a/eppoclient/version.go b/eppoclient/version.go index caef664..638deff 100644 --- a/eppoclient/version.go +++ b/eppoclient/version.go @@ -1,3 +1,3 @@ package eppoclient -var __version__ = "6.0.0" +var __version__ = "6.1.0"