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
22 changes: 14 additions & 8 deletions openfeature/multi/multiprovider.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
}
}

Expand Down Expand Up @@ -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")
Expand All @@ -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()
Expand Down
87 changes: 87 additions & 0 deletions openfeature/multi/multiprovider_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading