diff --git a/openfeature/multi/multiprovider.go b/openfeature/multi/multiprovider.go index 8ad9a5e7..56922833 100644 --- a/openfeature/multi/multiprovider.go +++ b/openfeature/multi/multiprovider.go @@ -118,7 +118,6 @@ var ( func init() { // used for mapping provider event types & provider states to comparable values for evaluation stateValues = map[of.State]int{ - "": -1, // Not a real state, but used for handling provider config changes of.ReadyState: 0, of.StaleState: 1, of.ErrorState: 2, @@ -130,10 +129,9 @@ func init() { of.ErrorState, // 2 } eventTypeToState = map[of.EventType]of.State{ - of.ProviderConfigChange: "", - of.ProviderReady: of.ReadyState, - of.ProviderStale: of.StaleState, - of.ProviderError: of.ErrorState, + of.ProviderReady: of.ReadyState, + of.ProviderStale: of.StaleState, + of.ProviderError: of.ErrorState, } } @@ -483,6 +481,16 @@ func (p *Provider) forwardProviderEvents(workerCtx context.Context, handlers cha slog.String(MetadataProviderType, e.ProviderName), ) l.LogAttrs(workerCtx, slog.LevelDebug, "received event from provider", slog.String("event-type", string(e.EventType))) + + // ConfigurationChanged events are always forwarded directly without affecting provider state tracking. + // This matches the JS SDK reference behavior where ConfigurationChanged is re-emitted as a direct + // pass-through, independent of status change logic. + if e.EventType == of.ProviderConfigChange { + p.outboundEvents <- e.Event + l.LogAttrs(workerCtx, slog.LevelDebug, "forwarded configuration changed event") + continue + } + if p.updateProviderStateFromEvent(e) { p.outboundEvents <- e.Event l.LogAttrs(workerCtx, slog.LevelDebug, "forwarded state update event") @@ -509,10 +517,8 @@ func (p *Provider) updateProviderState(name string, state of.State) bool { // updateProviderStateFromEvent updates the state of an internal provider from an event emitted from it, and then // re-evaluates the overall state of the multiprovider. If this method returns true the overall state changed. +// Note: ProviderConfigChange events are handled separately in forwardProviderEvents and never reach this method. func (p *Provider) updateProviderStateFromEvent(e namedEvent) bool { - if e.EventType == of.ProviderConfigChange { - p.logger.LogAttrs(context.Background(), slog.LevelDebug, "ProviderConfigChange event", slog.String("event-message", e.Message)) - } p.providerStatusLock.Lock() previousState := p.providerStatus[e.providerName] p.providerStatusLock.Unlock() diff --git a/openfeature/multi/multiprovider_test.go b/openfeature/multi/multiprovider_test.go index aa1ba2b6..a99df07f 100644 --- a/openfeature/multi/multiprovider_test.go +++ b/openfeature/multi/multiprovider_test.go @@ -371,6 +371,93 @@ func TestMultiProvider_StateUpdateWithSameTypeProviders(t *testing.T) { } } +func TestMultiProvider_ConfigurationChangedEventForwarding(t *testing.T) { + // awaitEvent drains the outbound channel until an event of the given type is found. + awaitEvent := func(t *testing.T, ch <-chan of.Event, eventType of.EventType) of.Event { + t.Helper() + var found of.Event + require.Eventually(t, func() bool { + for { + select { + case e, ok := <-ch: + if !ok { + return false + } + if e.EventType == eventType { + found = e + return true + } + default: + return false + } + } + }, time.Second, 10*time.Millisecond, "expected %s event was not received", eventType) + return found + } + + // setup creates a two-provider multi-provider, initializes it, and waits for READY. + setup := func(t *testing.T) (*Provider, *mockProviderWithEvents, *mockProviderWithEvents) { + t.Helper() + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + + p1 := newMockProviderWithEvents(ctrl, "provider1") + p2 := newMockProviderWithEvents(ctrl, "provider2") + + mp, err := NewProvider( + StrategyFirstMatch, + WithProvider("provider1", p1), + WithProvider("provider2", p2), + ) + require.NoError(t, err) + t.Cleanup(mp.Shutdown) + + require.NoError(t, mp.Init(of.NewEvaluationContext("test", nil))) + require.Eventually(t, func() bool { + return mp.Status() == of.ReadyState + }, time.Second, 10*time.Millisecond) + + return mp, p1, p2 + } + + t.Run("event is forwarded with correct payload and does not corrupt provider state", func(t *testing.T) { + mp, provider1, _ := setup(t) + + provider1.EmitEvent(of.ProviderConfigChange, "flags updated") + e := awaitEvent(t, mp.outboundEvents, of.ProviderConfigChange) + + assert.Equal(t, "flags updated", e.Message) + assert.Equal(t, "provider1", e.EventMetadata[MetadataProviderName]) + + // Provider state should remain READY (not corrupted to empty string) + mp.providerStatusLock.Lock() + assert.Equal(t, of.ReadyState, mp.providerStatus["provider1"]) + assert.Equal(t, of.ReadyState, mp.providerStatus["provider2"]) + mp.providerStatusLock.Unlock() + + assert.Equal(t, of.ReadyState, mp.Status()) + }) + + t.Run("does not affect aggregate state when another provider is degraded", func(t *testing.T) { + mp, provider1, provider2 := setup(t) + + // Put provider2 into STALE state + provider2.EmitEvent(of.ProviderStale, "stale") + awaitEvent(t, mp.outboundEvents, of.ProviderStale) + require.Equal(t, of.StaleState, mp.Status()) + + // ConfigurationChanged from provider1 should not affect aggregate + provider1.EmitEvent(of.ProviderConfigChange, "flags updated") + awaitEvent(t, mp.outboundEvents, of.ProviderConfigChange) + + assert.Equal(t, of.StaleState, mp.Status()) + + mp.providerStatusLock.Lock() + assert.Equal(t, of.ReadyState, mp.providerStatus["provider1"]) + mp.providerStatusLock.Unlock() + }) +} + func TestMultiProvider_Track(t *testing.T) { t.Run("forwards tracking to all ready providers that implement Tracker", func(t *testing.T) { ctrl := gomock.NewController(t)