Skip to content

Commit 7fd7ab6

Browse files
authored
fix(multi-provider): correct provider state tracking and improve robustness (#441)
- fix critical bug where provider state updates used wrong field (e.ProviderName instead of e.providerName), causing state tracking to fail when multiple providers of the same type were registered with different unique names - fix metadata merge not being assigned in comparison strategy when no fallback provider is configured - add buffering to outbound event channel to prevent blocking - adjust code comments - add debug logging when Shutdown() called on uninitialized provider Signed-off-by: Roman Dmytrenko <[email protected]>
1 parent c7c86d3 commit 7fd7ab6

File tree

4 files changed

+123
-7
lines changed

4 files changed

+123
-7
lines changed

openfeature/multi/comparison_strategy.go

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ type Comparator func(values []any) bool
2323
// there is a comparison failure -- prior to returning a default result. The [Comparator] parameter is optional and nil
2424
// can be passed as long as ObjectEvaluation is never called with objects that are not comparable. The custom [Comparator]
2525
// will only be used for [of.FeatureProvider.ObjectEvaluation] if set. If [of.FeatureProvider.ObjectEvaluation] is
26-
// called without setting a [Comparator], and the returned object(s) are not comparable, then a panic will occur.
26+
// called without setting a [Comparator], and the returned object(s) are not comparable, then an error will occur.
2727
func newComparisonStrategy(providers []*NamedProvider, fallbackProvider of.FeatureProvider, comparator Comparator) StrategyFn[FlagTypes] {
2828
return evaluateComparison[FlagTypes](providers, fallbackProvider, comparator)
2929
}
@@ -240,10 +240,9 @@ func evaluateComparison[T FlagTypes](providers []*NamedProvider, fallbackProvide
240240
}
241241

242242
defaultResult := BuildDefaultResult(StrategyComparison, defaultValue, errors.New("no fallback provider configured"))
243-
mergeFlagMeta(defaultResult.FlagMetadata, metadata)
243+
defaultResult.FlagMetadata = mergeFlagMeta(defaultResult.FlagMetadata, metadata)
244244
defaultResult.FlagMetadata[MetadataFallbackUsed] = false
245245
defaultResult.FlagMetadata[MetadataIsDefaultValue] = true
246-
247246
return defaultResult
248247
}
249248
}

openfeature/multi/multiprovider.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -247,7 +247,7 @@ func NewProvider(providerMap ProviderMap, evaluationStrategy EvaluationStrategy,
247247

248248
multiProvider := &Provider{
249249
providers: providers,
250-
outboundEvents: make(chan of.Event),
250+
outboundEvents: make(chan of.Event, len(providers)),
251251
logger: config.logger,
252252
metadata: buildMetadata(providerMap),
253253
overallStatus: of.NotReadyState,
@@ -476,7 +476,7 @@ func (p *Provider) updateProviderStateFromEvent(e namedEvent) bool {
476476
p.logger.LogAttrs(context.Background(), slog.LevelDebug, "ProviderConfigChange event", slog.String("event-message", e.Message))
477477
}
478478
logProviderState(p.logger, e, p.providerStatus[e.providerName])
479-
return p.updateProviderState(e.ProviderName, eventTypeToState[e.EventType])
479+
return p.updateProviderState(e.providerName, eventTypeToState[e.EventType])
480480
}
481481

482482
// evaluateState Determines the overall state of the provider using the weights specified in Appendix A of the
@@ -514,6 +514,7 @@ func logProviderState(l *slog.Logger, e namedEvent, previousState of.State) {
514514
func (p *Provider) Shutdown() {
515515
if !p.initialized {
516516
// Don't do anything if we were never initialized
517+
p.logger.LogAttrs(context.Background(), slog.LevelDebug, "provider not initialized, skipping shutdown")
517518
return
518519
}
519520
// Stop all event listener workers, shutdown events should not affect overall state

openfeature/multi/multiprovider_test.go

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"errors"
66
"regexp"
77
"testing"
8+
"time"
89

910
of "github.com/open-feature/go-sdk/openfeature"
1011
imp "github.com/open-feature/go-sdk/openfeature/memprovider"
@@ -344,3 +345,118 @@ func TestMultiProvider_statusEvaluation(t *testing.T) {
344345
assert.Equal(t, of.ErrorState, multiProvider.evaluateState())
345346
})
346347
}
348+
349+
func TestMultiProvider_StateUpdateWithSameTypeProviders(t *testing.T) {
350+
ctrl := gomock.NewController(t)
351+
t.Cleanup(ctrl.Finish)
352+
353+
// Create two mock providers with EventHandler support
354+
primaryProvider := newMockProviderWithEvents(ctrl, "MockProvider")
355+
secondaryProvider := newMockProviderWithEvents(ctrl, "MockProvider")
356+
357+
providers := ProviderMap{
358+
"primary": primaryProvider,
359+
"secondary": secondaryProvider,
360+
}
361+
362+
multiProvider, err := NewProvider(providers, StrategyFirstMatch)
363+
if err != nil {
364+
t.Fatalf("failed to create multi-provider: %v", err)
365+
}
366+
t.Cleanup(multiProvider.Shutdown)
367+
368+
// Initialize the provider
369+
ctx := of.NewEvaluationContext("test", nil)
370+
if err := multiProvider.Init(ctx); err != nil {
371+
t.Fatalf("failed to initialize multi-provider: %v", err)
372+
}
373+
374+
primaryProvider.EmitEvent(of.ProviderError, "fail to fetch data")
375+
secondaryProvider.EmitEvent(of.ProviderReady, "rev 1")
376+
377+
time.Sleep(200 * time.Millisecond)
378+
379+
// Check the state after the error event
380+
multiProvider.providerStatusLock.Lock()
381+
primaryState := multiProvider.providerStatus["primary"]
382+
secondaryState := multiProvider.providerStatus["secondary"]
383+
numProviders := len(multiProvider.providerStatus)
384+
multiProvider.providerStatusLock.Unlock()
385+
386+
if primaryState != of.ErrorState {
387+
t.Errorf("Expected primary-mock state to be ERROR after emitting error event, got %s", primaryState)
388+
}
389+
390+
if secondaryState != of.ReadyState {
391+
t.Errorf("Expected secondary-mock state to be READY, got %s", secondaryState)
392+
}
393+
394+
if numProviders != 2 {
395+
t.Errorf("Expected 2 providers in status map, got %d", numProviders)
396+
}
397+
}
398+
399+
var _ of.StateHandler = (*mockProviderWithEvents)(nil)
400+
401+
// mockProviderWithEvents wraps a mock provider to add EventHandler capability
402+
type mockProviderWithEvents struct {
403+
*of.MockFeatureProvider
404+
*of.MockStateHandler
405+
eventChannel chan of.Event
406+
metadata of.Metadata
407+
}
408+
409+
func newMockProviderWithEvents(ctrl *gomock.Controller, name string) *mockProviderWithEvents {
410+
mockProvider := of.NewMockFeatureProvider(ctrl)
411+
mockStateHandler := of.NewMockStateHandler(ctrl)
412+
eventChan := make(chan of.Event, 10)
413+
414+
metadata := of.Metadata{Name: name}
415+
416+
// Set up expectations
417+
mockProvider.EXPECT().Metadata().Return(metadata).AnyTimes()
418+
mockProvider.EXPECT().Hooks().Return([]of.Hook{}).AnyTimes()
419+
mockStateHandler.EXPECT().Init(gomock.Any()).DoAndReturn(func(ctx of.EvaluationContext) error {
420+
// Emit READY event on init
421+
eventChan <- of.Event{
422+
ProviderName: name,
423+
EventType: of.ProviderReady,
424+
ProviderEventDetails: of.ProviderEventDetails{
425+
EventMetadata: make(map[string]any),
426+
},
427+
}
428+
return nil
429+
}).AnyTimes()
430+
mockStateHandler.EXPECT().Shutdown()
431+
432+
return &mockProviderWithEvents{
433+
MockFeatureProvider: mockProvider,
434+
MockStateHandler: mockStateHandler,
435+
eventChannel: eventChan,
436+
metadata: metadata,
437+
}
438+
}
439+
440+
func (m *mockProviderWithEvents) Init(evalCtx of.EvaluationContext) error {
441+
return m.MockStateHandler.Init(evalCtx)
442+
}
443+
444+
func (m *mockProviderWithEvents) Shutdown() {
445+
m.MockStateHandler.Shutdown()
446+
close(m.eventChannel)
447+
}
448+
449+
func (m *mockProviderWithEvents) EventChannel() <-chan of.Event {
450+
return m.eventChannel
451+
}
452+
453+
func (m *mockProviderWithEvents) EmitEvent(eventType of.EventType, message string) {
454+
m.eventChannel <- of.Event{
455+
ProviderName: m.metadata.Name,
456+
EventType: eventType,
457+
ProviderEventDetails: of.ProviderEventDetails{
458+
Message: message,
459+
EventMetadata: make(map[string]any),
460+
},
461+
}
462+
}

openfeature/multi/strategies.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,8 @@ const (
1515
// This is executed sequentially, and not in parallel. Any returned errors from a provider other than flag not found
1616
// will result in a default response with a set error.
1717
StrategyFirstMatch EvaluationStrategy = "strategy-first-match"
18-
// StrategyFirstSuccess returns the result of the First [of.FeatureProvider] whose response that is not an error.
19-
// This is very similar to [StrategyFirstMatch], but does not raise errors. This executed sequentially.
18+
// StrategyFirstSuccess returns the result of the first [of.FeatureProvider] whose response that is not an error.
19+
// This is very similar to [StrategyFirstMatch], but does not raise errors. This is executed sequentially.
2020
StrategyFirstSuccess EvaluationStrategy = "strategy-first-success"
2121
// StrategyComparison returns a response of all [of.FeatureProvider] instances in agreement. All providers are
2222
// called in parallel and then the results of each non-error result are compared to each other. If all responses

0 commit comments

Comments
 (0)