diff --git a/internal/server/evaluation/evaluation.go b/internal/server/evaluation/evaluation.go index f810029746..2f5e81f2f8 100644 --- a/internal/server/evaluation/evaluation.go +++ b/internal/server/evaluation/evaluation.go @@ -3,18 +3,19 @@ package evaluation import ( "context" + errs "go.flipt.io/flipt/errors" fliptotel "go.flipt.io/flipt/internal/server/otel" "go.flipt.io/flipt/internal/storage" "go.flipt.io/flipt/rpc/flipt" - rpcEvaluation "go.flipt.io/flipt/rpc/flipt/evaluation" + rpcevaluation "go.flipt.io/flipt/rpc/flipt/evaluation" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/trace" "go.uber.org/zap" ) // Variant evaluates a request for a multi-variate flag and entity. -func (s *Server) Variant(ctx context.Context, v *rpcEvaluation.EvaluationRequest) (*rpcEvaluation.VariantEvaluationResponse, error) { - var ver = &rpcEvaluation.VariantEvaluationResponse{} +func (s *Server) Variant(ctx context.Context, v *rpcevaluation.EvaluationRequest) (*rpcevaluation.VariantEvaluationResponse, error) { + var ver = &rpcevaluation.VariantEvaluationResponse{} flag, err := s.store.GetFlag(ctx, v.NamespaceKey, v.FlagKey) if err != nil { @@ -55,7 +56,7 @@ func (s *Server) Variant(ctx context.Context, v *rpcEvaluation.EvaluationRequest ver.Match = resp.Match ver.SegmentKey = resp.SegmentKey - ver.Reason = rpcEvaluation.EvaluationReason(resp.Reason) + ver.Reason = rpcevaluation.EvaluationReason(resp.Reason) ver.VariantKey = resp.Value ver.VariantAttachment = resp.Attachment @@ -66,3 +67,27 @@ func (s *Server) Variant(ctx context.Context, v *rpcEvaluation.EvaluationRequest s.logger.Debug("variant", zap.Stringer("response", resp)) return ver, nil } + +// Boolean evaluates a request for a boolean flag and entity. +func (s *Server) Boolean(ctx context.Context, r *rpcevaluation.EvaluationRequest) (*rpcevaluation.BooleanEvaluationResponse, error) { + flag, err := s.store.GetFlag(ctx, r.NamespaceKey, r.FlagKey) + if err != nil { + return nil, err + } + + if flag.Type != flipt.FlagType_BOOLEAN_FLAG_TYPE { + return nil, errs.ErrInvalidf("flag type %s invalid", flag.Type) + } + + rollouts, err := s.store.GetEvaluationRollouts(ctx, r.NamespaceKey, r.FlagKey) + if err != nil { + return nil, err + } + + resp, err := s.evaluator.booleanMatch(r, flag.Enabled, rollouts) + if err != nil { + return nil, err + } + + return resp, nil +} diff --git a/internal/server/evaluation/evaluation_store_mock.go b/internal/server/evaluation/evaluation_store_mock.go index cd8f5fa626..02411fe81e 100644 --- a/internal/server/evaluation/evaluation_store_mock.go +++ b/internal/server/evaluation/evaluation_store_mock.go @@ -8,7 +8,7 @@ import ( flipt "go.flipt.io/flipt/rpc/flipt" ) -var _ EvaluationStorer = &evaluationStoreMock{} +var _ Storer = &evaluationStoreMock{} type evaluationStoreMock struct { mock.Mock @@ -32,3 +32,8 @@ func (e *evaluationStoreMock) GetEvaluationDistributions(ctx context.Context, ru args := e.Called(ctx, ruleID) return args.Get(0).([]*storage.EvaluationDistribution), args.Error(1) } + +func (e *evaluationStoreMock) GetEvaluationRollouts(ctx context.Context, namespaceKey, flagKey string) ([]*storage.EvaluationRollout, error) { + args := e.Called(ctx, namespaceKey, flagKey) + return args.Get(0).([]*storage.EvaluationRollout), args.Error(1) +} diff --git a/internal/server/evaluation/evaluation_test.go b/internal/server/evaluation/evaluation_test.go index 8c01434ad3..43e252b4d8 100644 --- a/internal/server/evaluation/evaluation_test.go +++ b/internal/server/evaluation/evaluation_test.go @@ -8,8 +8,9 @@ import ( "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" errs "go.flipt.io/flipt/errors" + "go.flipt.io/flipt/internal/storage" "go.flipt.io/flipt/rpc/flipt" - rpcEvaluation "go.flipt.io/flipt/rpc/flipt/evaluation" + rpcevaluation "go.flipt.io/flipt/rpc/flipt/evaluation" "go.uber.org/zap/zaptest" ) @@ -24,7 +25,7 @@ func TestVariant_FlagNotFound(t *testing.T) { store.On("GetFlag", mock.Anything, namespaceKey, flagKey).Return(&flipt.Flag{}, errs.ErrNotFound("test-flag")) - v, err := s.Variant(context.TODO(), &rpcEvaluation.EvaluationRequest{ + v, err := s.Variant(context.TODO(), &rpcevaluation.EvaluationRequest{ FlagKey: flagKey, EntityId: "test-entity", NamespaceKey: namespaceKey, @@ -54,7 +55,7 @@ func TestVariant_NonVariantFlag(t *testing.T) { Type: flipt.FlagType_BOOLEAN_FLAG_TYPE, }, nil) - v, err := s.Variant(context.TODO(), &rpcEvaluation.EvaluationRequest{ + v, err := s.Variant(context.TODO(), &rpcevaluation.EvaluationRequest{ FlagKey: flagKey, EntityId: "test-entity", NamespaceKey: namespaceKey, @@ -68,19 +69,14 @@ func TestVariant_NonVariantFlag(t *testing.T) { assert.EqualError(t, err, "flag type BOOLEAN_FLAG_TYPE invalid") } -func TestVariant_EvaluateFailure(t *testing.T) { +func TestVariant_EvaluateFailure_OnGetEvaluationRules(t *testing.T) { var ( flagKey = "test-flag" namespaceKey = "test-namespace" store = &evaluationStoreMock{} - evaluator = &evaluatorMock{} logger = zaptest.NewLogger(t) - s = &Server{ - logger: logger, - store: store, - evaluator: evaluator, - } - flag = &flipt.Flag{ + s = New(logger, store) + flag = &flipt.Flag{ NamespaceKey: namespaceKey, Key: flagKey, Enabled: true, @@ -90,16 +86,9 @@ func TestVariant_EvaluateFailure(t *testing.T) { store.On("GetFlag", mock.Anything, namespaceKey, flagKey).Return(flag, nil) - evaluator.On("Evaluate", mock.Anything, flag, &flipt.EvaluationRequest{ - FlagKey: flagKey, - NamespaceKey: namespaceKey, - EntityId: "test-entity", - Context: map[string]string{ - "hello": "world", - }, - }).Return(&flipt.EvaluationResponse{}, errs.ErrInvalid("some error")) + store.On("GetEvaluationRules", mock.Anything, namespaceKey, flagKey).Return([]*storage.EvaluationRule{}, errs.ErrInvalid("some invalid error")) - v, err := s.Variant(context.TODO(), &rpcEvaluation.EvaluationRequest{ + v, err := s.Variant(context.TODO(), &rpcevaluation.EvaluationRequest{ FlagKey: flagKey, EntityId: "test-entity", NamespaceKey: namespaceKey, @@ -110,7 +99,7 @@ func TestVariant_EvaluateFailure(t *testing.T) { require.Nil(t, v) - assert.EqualError(t, err, "some error") + assert.EqualError(t, err, "some invalid error") } func TestVariant_Success(t *testing.T) { @@ -118,14 +107,9 @@ func TestVariant_Success(t *testing.T) { flagKey = "test-flag" namespaceKey = "test-namespace" store = &evaluationStoreMock{} - evaluator = &evaluatorMock{} logger = zaptest.NewLogger(t) - s = &Server{ - logger: logger, - store: store, - evaluator: evaluator, - } - flag = &flipt.Flag{ + s = New(logger, store) + flag = &flipt.Flag{ NamespaceKey: namespaceKey, Key: flagKey, Enabled: true, @@ -135,24 +119,326 @@ func TestVariant_Success(t *testing.T) { store.On("GetFlag", mock.Anything, namespaceKey, flagKey).Return(flag, nil) - evaluator.On("Evaluate", mock.Anything, flag, &flipt.EvaluationRequest{ + store.On("GetEvaluationRules", mock.Anything, namespaceKey, flagKey).Return( + []*storage.EvaluationRule{ + { + ID: "1", + FlagKey: flagKey, + SegmentKey: "bar", + SegmentMatchType: flipt.MatchType_ALL_MATCH_TYPE, + Rank: 0, + Constraints: []storage.EvaluationConstraint{ + { + ID: "2", + Type: flipt.ComparisonType_STRING_COMPARISON_TYPE, + Property: "hello", + Operator: flipt.OpEQ, + Value: "world", + }, + }, + }, + }, nil) + + store.On("GetEvaluationDistributions", mock.Anything, "1").Return([]*storage.EvaluationDistribution{}, nil) + + v, err := s.Variant(context.TODO(), &rpcevaluation.EvaluationRequest{ FlagKey: flagKey, + EntityId: "test-entity", NamespaceKey: namespaceKey, + Context: map[string]string{ + "hello": "world", + }, + }) + + require.NoError(t, err) + + assert.Equal(t, true, v.Match) + assert.Equal(t, "bar", v.SegmentKey) + assert.Equal(t, rpcevaluation.EvaluationReason_MATCH_EVALUATION_REASON, v.Reason) +} + +func TestBoolean_FlagNotFoundError(t *testing.T) { + var ( + flagKey = "test-flag" + namespaceKey = "test-namespace" + store = &evaluationStoreMock{} + logger = zaptest.NewLogger(t) + s = New(logger, store) + ) + defer store.AssertNotCalled(t, "GetEvaluationRollouts", mock.Anything, namespaceKey, flagKey) + + store.On("GetFlag", mock.Anything, mock.Anything, mock.Anything).Return(&flipt.Flag{}, errs.ErrNotFound("test-flag")) + + res, err := s.Boolean(context.TODO(), &rpcevaluation.EvaluationRequest{ + FlagKey: flagKey, EntityId: "test-entity", + NamespaceKey: namespaceKey, Context: map[string]string{ "hello": "world", }, - }).Return( - &flipt.EvaluationResponse{ - FlagKey: flagKey, + }) + + require.Nil(t, res) + + assert.EqualError(t, err, "test-flag not found") +} + +func TestBoolean_NonBooleanFlagError(t *testing.T) { + var ( + flagKey = "test-flag" + namespaceKey = "test-namespace" + store = &evaluationStoreMock{} + logger = zaptest.NewLogger(t) + s = New(logger, store) + ) + defer store.AssertNotCalled(t, "GetEvaluationRollouts", mock.Anything, namespaceKey, flagKey) + + store.On("GetFlag", mock.Anything, mock.Anything, mock.Anything).Return(&flipt.Flag{ + NamespaceKey: "test-namespace", + Key: "test-flag", + Enabled: true, + Type: flipt.FlagType_VARIANT_FLAG_TYPE, + }, nil) + + res, err := s.Boolean(context.TODO(), &rpcevaluation.EvaluationRequest{ + FlagKey: flagKey, + EntityId: "test-entity", + NamespaceKey: namespaceKey, + Context: map[string]string{ + "hello": "world", + }, + }) + + require.Nil(t, res) + + assert.EqualError(t, err, "flag type VARIANT_FLAG_TYPE invalid") +} + +func TestBoolean_DefaultRule_NoRollouts(t *testing.T) { + var ( + flagKey = "test-flag" + namespaceKey = "test-namespace" + store = &evaluationStoreMock{} + logger = zaptest.NewLogger(t) + s = New(logger, store) + ) + + store.On("GetFlag", mock.Anything, mock.Anything, mock.Anything).Return(&flipt.Flag{ + NamespaceKey: "test-namespace", + Key: "test-flag", + Enabled: true, + Type: flipt.FlagType_BOOLEAN_FLAG_TYPE, + }, nil) + + store.On("GetEvaluationRollouts", mock.Anything, namespaceKey, flagKey).Return([]*storage.EvaluationRollout{}, nil) + + res, err := s.Boolean(context.TODO(), &rpcevaluation.EvaluationRequest{ + FlagKey: flagKey, + EntityId: "test-entity", + NamespaceKey: namespaceKey, + Context: map[string]string{ + "hello": "world", + }, + }) + + require.NoError(t, err) + + assert.Equal(t, true, res.Value) + assert.Equal(t, rpcevaluation.EvaluationReason_DEFAULT_EVALUATION_REASON, res.Reason) +} + +func TestBoolean_DefaultRuleFallthrough_WithPercentageRollout(t *testing.T) { + var ( + flagKey = "test-flag" + namespaceKey = "test-namespace" + store = &evaluationStoreMock{} + logger = zaptest.NewLogger(t) + s = New(logger, store) + ) + + store.On("GetFlag", mock.Anything, mock.Anything, mock.Anything).Return(&flipt.Flag{ + NamespaceKey: "test-namespace", + Key: "test-flag", + Enabled: true, + Type: flipt.FlagType_BOOLEAN_FLAG_TYPE, + }, nil) + + store.On("GetEvaluationRollouts", mock.Anything, namespaceKey, flagKey).Return([]*storage.EvaluationRollout{ + { NamespaceKey: namespaceKey, - Value: "foo", - Match: true, - SegmentKey: "segment", - Reason: flipt.EvaluationReason(rpcEvaluation.EvaluationReason_MATCH_EVALUATION_REASON), + Rank: 1, + RolloutType: flipt.RolloutType_PERCENTAGE_ROLLOUT_TYPE, + Percentage: &storage.RolloutPercentage{ + Percentage: 5, + Value: false, + }, + }, + }, nil) + + res, err := s.Boolean(context.TODO(), &rpcevaluation.EvaluationRequest{ + FlagKey: flagKey, + EntityId: "test-entity", + NamespaceKey: namespaceKey, + Context: map[string]string{ + "hello": "world", + }, + }) + + require.NoError(t, err) + + assert.Equal(t, true, res.Value) + assert.Equal(t, rpcevaluation.EvaluationReason_DEFAULT_EVALUATION_REASON, res.Reason) +} + +func TestBoolean_PercentageRuleMatch(t *testing.T) { + var ( + flagKey = "test-flag" + namespaceKey = "test-namespace" + store = &evaluationStoreMock{} + logger = zaptest.NewLogger(t) + s = New(logger, store) + ) + + store.On("GetFlag", mock.Anything, namespaceKey, flagKey).Return(&flipt.Flag{ + NamespaceKey: "test-namespace", + Key: "test-flag", + Enabled: true, + Type: flipt.FlagType_BOOLEAN_FLAG_TYPE, + }, nil) + + store.On("GetEvaluationRollouts", mock.Anything, namespaceKey, flagKey).Return([]*storage.EvaluationRollout{ + { + NamespaceKey: namespaceKey, + Rank: 1, + RolloutType: flipt.RolloutType_PERCENTAGE_ROLLOUT_TYPE, + Percentage: &storage.RolloutPercentage{ + Percentage: 70, + Value: false, + }, + }, + }, nil) + + res, err := s.Boolean(context.TODO(), &rpcevaluation.EvaluationRequest{ + FlagKey: flagKey, + EntityId: "test-entity", + NamespaceKey: namespaceKey, + Context: map[string]string{ + "hello": "world", + }, + }) + + require.NoError(t, err) + + assert.Equal(t, false, res.Value) + assert.Equal(t, rpcevaluation.EvaluationReason_MATCH_EVALUATION_REASON, res.Reason) +} + +func TestBoolean_PercentageRuleFallthrough_SegmentMatch(t *testing.T) { + var ( + flagKey = "test-flag" + namespaceKey = "test-namespace" + store = &evaluationStoreMock{} + logger = zaptest.NewLogger(t) + s = New(logger, store) + ) + + store.On("GetFlag", mock.Anything, namespaceKey, flagKey).Return(&flipt.Flag{ + NamespaceKey: "test-namespace", + Key: "test-flag", + Enabled: true, + Type: flipt.FlagType_BOOLEAN_FLAG_TYPE, + }, nil) + + store.On("GetEvaluationRollouts", mock.Anything, namespaceKey, flagKey).Return([]*storage.EvaluationRollout{ + { + NamespaceKey: namespaceKey, + Rank: 1, + RolloutType: flipt.RolloutType_PERCENTAGE_ROLLOUT_TYPE, + Percentage: &storage.RolloutPercentage{ + Percentage: 5, + Value: false, + }, + }, + { + NamespaceKey: namespaceKey, + RolloutType: flipt.RolloutType_SEGMENT_ROLLOUT_TYPE, + Rank: 2, + Segment: &storage.RolloutSegment{ + SegmentKey: "test-segment", + SegmentMatchType: flipt.MatchType_ANY_MATCH_TYPE, + Value: true, + Constraints: []storage.EvaluationConstraint{ + { + Type: flipt.ComparisonType_STRING_COMPARISON_TYPE, + Property: "hello", + Operator: flipt.OpEQ, + Value: "world", + }, + }, + }, + }, + }, nil) + + res, err := s.Boolean(context.TODO(), &rpcevaluation.EvaluationRequest{ + FlagKey: flagKey, + EntityId: "test-entity", + NamespaceKey: namespaceKey, + Context: map[string]string{ + "hello": "world", + }, + }) + + require.NoError(t, err) + + assert.Equal(t, true, res.Value) + assert.Equal(t, rpcevaluation.EvaluationReason_MATCH_EVALUATION_REASON, res.Reason) +} + +func TestBoolean_SegmentMatch_MultipleConstraints(t *testing.T) { + var ( + flagKey = "test-flag" + namespaceKey = "test-namespace" + store = &evaluationStoreMock{} + logger = zaptest.NewLogger(t) + s = New(logger, store) + ) + + store.On("GetFlag", mock.Anything, namespaceKey, flagKey).Return( + &flipt.Flag{ + NamespaceKey: "test-namespace", + Key: "test-flag", + Enabled: true, + Type: flipt.FlagType_BOOLEAN_FLAG_TYPE, }, nil) - v, err := s.Variant(context.TODO(), &rpcEvaluation.EvaluationRequest{ + store.On("GetEvaluationRollouts", mock.Anything, namespaceKey, flagKey).Return([]*storage.EvaluationRollout{ + { + NamespaceKey: namespaceKey, + RolloutType: flipt.RolloutType_SEGMENT_ROLLOUT_TYPE, + Rank: 1, + Segment: &storage.RolloutSegment{ + SegmentKey: "test-segment", + SegmentMatchType: flipt.MatchType_ANY_MATCH_TYPE, + Value: true, + Constraints: []storage.EvaluationConstraint{ + { + Type: flipt.ComparisonType_NUMBER_COMPARISON_TYPE, + Property: "pitimes100", + Operator: flipt.OpEQ, + Value: "314", + }, + { + Type: flipt.ComparisonType_STRING_COMPARISON_TYPE, + Property: "hello", + Operator: flipt.OpEQ, + Value: "world", + }, + }, + }, + }, + }, nil) + + res, err := s.Boolean(context.TODO(), &rpcevaluation.EvaluationRequest{ FlagKey: flagKey, EntityId: "test-entity", NamespaceKey: namespaceKey, @@ -163,8 +449,67 @@ func TestVariant_Success(t *testing.T) { require.NoError(t, err) - assert.Equal(t, true, v.Match) - assert.Equal(t, "foo", v.VariantKey) - assert.Equal(t, "segment", v.SegmentKey) - assert.Equal(t, rpcEvaluation.EvaluationReason_MATCH_EVALUATION_REASON, v.Reason) + assert.Equal(t, true, res.Value) + assert.Equal(t, rpcevaluation.EvaluationReason_MATCH_EVALUATION_REASON, res.Reason) +} + +func TestBoolean_RulesOutOfOrder(t *testing.T) { + var ( + flagKey = "test-flag" + namespaceKey = "test-namespace" + store = &evaluationStoreMock{} + logger = zaptest.NewLogger(t) + s = New(logger, store) + ) + + store.On("GetFlag", mock.Anything, namespaceKey, flagKey).Return( + &flipt.Flag{ + NamespaceKey: "test-namespace", + Key: "test-flag", + Enabled: true, + Type: flipt.FlagType_BOOLEAN_FLAG_TYPE, + }, nil) + + store.On("GetEvaluationRollouts", mock.Anything, namespaceKey, flagKey).Return([]*storage.EvaluationRollout{ + { + NamespaceKey: namespaceKey, + Rank: 1, + RolloutType: flipt.RolloutType_PERCENTAGE_ROLLOUT_TYPE, + Percentage: &storage.RolloutPercentage{ + Percentage: 5, + Value: false, + }, + }, + { + NamespaceKey: namespaceKey, + RolloutType: flipt.RolloutType_SEGMENT_ROLLOUT_TYPE, + Rank: 0, + Segment: &storage.RolloutSegment{ + SegmentKey: "test-segment", + SegmentMatchType: flipt.MatchType_ANY_MATCH_TYPE, + Value: true, + Constraints: []storage.EvaluationConstraint{ + { + Type: flipt.ComparisonType_STRING_COMPARISON_TYPE, + Property: "hello", + Operator: flipt.OpEQ, + Value: "world", + }, + }, + }, + }, + }, nil) + + res, err := s.Boolean(context.TODO(), &rpcevaluation.EvaluationRequest{ + FlagKey: flagKey, + EntityId: "test-entity", + NamespaceKey: namespaceKey, + Context: map[string]string{ + "hello": "world", + }, + }) + + require.Nil(t, res) + + assert.EqualError(t, err, "rollout rank: 0 detected out of order") } diff --git a/internal/server/evaluation/evaluator.go b/internal/server/evaluation/evaluator.go index 2b64c63c93..0e927eddc6 100644 --- a/internal/server/evaluation/evaluator.go +++ b/internal/server/evaluation/evaluator.go @@ -2,6 +2,7 @@ package evaluation import ( "context" + "fmt" "hash/crc32" "sort" "strconv" @@ -12,26 +13,20 @@ import ( "go.flipt.io/flipt/internal/server/metrics" "go.flipt.io/flipt/internal/storage" "go.flipt.io/flipt/rpc/flipt" + rpcevaluation "go.flipt.io/flipt/rpc/flipt/evaluation" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/metric" "go.uber.org/zap" ) -// EvaluationStorer is the minimal abstraction for interacting with the storage layer for evaluation. -type EvaluationStorer interface { - GetFlag(ctx context.Context, namespaceKey, key string) (*flipt.Flag, error) - GetEvaluationRules(ctx context.Context, namespaceKey string, flagKey string) ([]*storage.EvaluationRule, error) - GetEvaluationDistributions(ctx context.Context, ruleID string) ([]*storage.EvaluationDistribution, error) -} - -// Evaluator is responsible for legacy evaluations. +// Evaluator is an evaluator for legacy flag evaluations. type Evaluator struct { logger *zap.Logger - store EvaluationStorer + store Storer } // NewEvaluator is the constructor for an Evaluator. -func NewEvaluator(logger *zap.Logger, store EvaluationStorer) *Evaluator { +func NewEvaluator(logger *zap.Logger, store Storer) *Evaluator { return &Evaluator{ logger: logger, store: store, @@ -121,81 +116,13 @@ func (e *Evaluator) Evaluate(ctx context.Context, flag *flipt.Flag, r *flipt.Eva lastRank = rule.Rank - constraintMatches := 0 - - // constraint loop - for _, c := range rule.Constraints { - v := r.Context[c.Property] - - var ( - match bool - err error - ) - - switch c.Type { - case flipt.ComparisonType_STRING_COMPARISON_TYPE: - match = matchesString(c, v) - case flipt.ComparisonType_NUMBER_COMPARISON_TYPE: - match, err = matchesNumber(c, v) - case flipt.ComparisonType_BOOLEAN_COMPARISON_TYPE: - match, err = matchesBool(c, v) - case flipt.ComparisonType_DATETIME_COMPARISON_TYPE: - match, err = matchesDateTime(c, v) - default: - resp.Reason = flipt.EvaluationReason_ERROR_EVALUATION_REASON - return resp, errs.ErrInvalid("unknown constraint type") - } - - if err != nil { - resp.Reason = flipt.EvaluationReason_ERROR_EVALUATION_REASON - return resp, err - } - - if match { - e.logger.Debug("constraint matches", zap.Reflect("constraint", c)) - - // increase the matchCount - constraintMatches++ - - switch rule.SegmentMatchType { - case flipt.MatchType_ANY_MATCH_TYPE: - // can short circuit here since we had at least one match - break - default: - // keep looping as we need to match all constraints - continue - } - } else { - // no match - e.logger.Debug("constraint does not match", zap.Reflect("constraint", c)) - - switch rule.SegmentMatchType { - case flipt.MatchType_ALL_MATCH_TYPE: - // we can short circuit because we must match all constraints - break - default: - // keep looping to see if we match the next constraint - continue - } - } - } // end constraint loop - - switch rule.SegmentMatchType { - case flipt.MatchType_ALL_MATCH_TYPE: - if len(rule.Constraints) != constraintMatches { - // all constraints did not match, continue to next rule - e.logger.Debug("did not match ALL constraints") - continue - } + matched, err := e.matchConstraints(r.Context, rule.Constraints, rule.SegmentMatchType) + if err != nil { + resp.Reason = flipt.EvaluationReason_ERROR_EVALUATION_REASON + return resp, err + } - case flipt.MatchType_ANY_MATCH_TYPE: - if len(rule.Constraints) > 0 && constraintMatches == 0 { - // no constraints matched, continue to next rule - e.logger.Debug("did not match ANY constraints") - continue - } - default: - e.logger.Error("unknown match type", zap.Int32("match_type", int32(rule.SegmentMatchType))) + if !matched { continue } @@ -265,6 +192,137 @@ func (e *Evaluator) Evaluate(ctx context.Context, flag *flipt.Flag, r *flipt.Eva return resp, nil } +func (e *Evaluator) booleanMatch(r *rpcevaluation.EvaluationRequest, flagValue bool, rollouts []*storage.EvaluationRollout) (*rpcevaluation.BooleanEvaluationResponse, error) { + resp := &rpcevaluation.BooleanEvaluationResponse{} + + var lastRank int32 + + for _, rollout := range rollouts { + if rollout.Rank < lastRank { + return nil, fmt.Errorf("rollout rank: %d detected out of order", rollout.Rank) + } + + lastRank = rollout.Rank + + if rollout.Percentage != nil { + // consistent hashing based on the entity id and flag key. + hash := crc32.ChecksumIEEE([]byte(r.EntityId + r.FlagKey)) + + normalizedValue := float32(int(hash) % 100) + + // if this case does not hold, fall through to the next rollout. + if normalizedValue < rollout.Percentage.Percentage { + resp.Value = rollout.Percentage.Value + resp.Reason = rpcevaluation.EvaluationReason_MATCH_EVALUATION_REASON + e.logger.Debug("percentage based matched", zap.Int("rank", int(rollout.Rank)), zap.String("rollout_type", "percentage")) + + return resp, nil + } + } else if rollout.Segment != nil { + matched, err := e.matchConstraints(r.Context, rollout.Segment.Constraints, rollout.Segment.SegmentMatchType) + if err != nil { + return nil, err + } + + // if we don't match the segment, fall through to the next rollout. + if !matched { + continue + } + + resp.Value = rollout.Segment.Value + resp.Reason = rpcevaluation.EvaluationReason_MATCH_EVALUATION_REASON + + e.logger.Debug("segment based matched", zap.Int("rank", int(rollout.Rank)), zap.String("segment", rollout.Segment.SegmentKey)) + + return resp, nil + } + } + + // If we have exhausted all rollouts and we still don't have a match, return the default value. + resp.Reason = rpcevaluation.EvaluationReason_DEFAULT_EVALUATION_REASON + resp.Value = flagValue + e.logger.Debug("default rollout matched", zap.Bool("value", flagValue)) + + return resp, nil +} + +// matchConstraints is a utility function that will return if all or any constraints have matched for a segment depending +// on the match type. +func (e *Evaluator) matchConstraints(evalCtx map[string]string, constraints []storage.EvaluationConstraint, segmentMatchType flipt.MatchType) (bool, error) { + constraintMatches := 0 + + for _, c := range constraints { + v := evalCtx[c.Property] + + var ( + match bool + err error + ) + + switch c.Type { + case flipt.ComparisonType_STRING_COMPARISON_TYPE: + match = matchesString(c, v) + case flipt.ComparisonType_NUMBER_COMPARISON_TYPE: + match, err = matchesNumber(c, v) + case flipt.ComparisonType_BOOLEAN_COMPARISON_TYPE: + match, err = matchesBool(c, v) + case flipt.ComparisonType_DATETIME_COMPARISON_TYPE: + match, err = matchesDateTime(c, v) + default: + return false, errs.ErrInvalid("unknown constraint type") + } + + if err != nil { + return false, err + } + + if match { + // increase the matchCount + constraintMatches++ + + switch segmentMatchType { + case flipt.MatchType_ANY_MATCH_TYPE: + // can short circuit here since we had at least one match + break + default: + // keep looping as we need to match all constraints + continue + } + } else { + // no match + switch segmentMatchType { + case flipt.MatchType_ALL_MATCH_TYPE: + // we can short circuit because we must match all constraints + break + default: + // keep looping to see if we match the next constraint + continue + } + } + } + + var matched = true + + switch segmentMatchType { + case flipt.MatchType_ALL_MATCH_TYPE: + if len(constraints) != constraintMatches { + e.logger.Debug("did not match ALL constraints") + matched = false + } + + case flipt.MatchType_ANY_MATCH_TYPE: + if len(constraints) > 0 && constraintMatches == 0 { + e.logger.Debug("did not match ANY constraints") + matched = false + } + default: + e.logger.Error("unknown match type", zap.Int32("match_type", int32(segmentMatchType))) + matched = false + } + + return matched, nil +} + func crc32Num(entityID string, salt string) uint { return uint(crc32.ChecksumIEEE([]byte(salt+entityID))) % totalBucketNum } diff --git a/internal/server/evaluation/evaluator_mock.go b/internal/server/evaluation/evaluator_mock.go deleted file mode 100644 index f57e7464cf..0000000000 --- a/internal/server/evaluation/evaluator_mock.go +++ /dev/null @@ -1,21 +0,0 @@ -package evaluation - -import ( - "context" - - "github.com/stretchr/testify/mock" - "go.flipt.io/flipt/rpc/flipt" -) - -type evaluatorMock struct { - mock.Mock -} - -func (e *evaluatorMock) String() string { - return "mock" -} - -func (e *evaluatorMock) Evaluate(ctx context.Context, flag *flipt.Flag, er *flipt.EvaluationRequest) (*flipt.EvaluationResponse, error) { - args := e.Called(ctx, flag, er) - return args.Get(0).(*flipt.EvaluationResponse), args.Error(1) -} diff --git a/internal/server/evaluation/evaluator_test.go b/internal/server/evaluation/evaluator_test.go index 5bc9f535bc..902fb93d0e 100644 --- a/internal/server/evaluation/evaluator_test.go +++ b/internal/server/evaluation/evaluator_test.go @@ -896,6 +896,87 @@ func TestEvaluator_RulesOutOfOrder(t *testing.T) { assert.Equal(t, flipt.EvaluationReason_ERROR_EVALUATION_REASON, resp.Reason) } +func TestEvaluator_ErrorParsingNumber(t *testing.T) { + var ( + store = &evaluationStoreMock{} + logger = zaptest.NewLogger(t) + s = NewEvaluator(logger, store) + ) + + store.On("GetEvaluationRules", mock.Anything, mock.Anything, "foo").Return( + []*storage.EvaluationRule{ + { + ID: "1", + FlagKey: "foo", + SegmentKey: "bar", + SegmentMatchType: flipt.MatchType_ALL_MATCH_TYPE, + Rank: 1, + Constraints: []storage.EvaluationConstraint{ + { + ID: "2", + Type: flipt.ComparisonType_NUMBER_COMPARISON_TYPE, + Property: "bar", + Operator: flipt.OpEQ, + Value: "boz", + }, + }, + }, + }, nil) + + resp, err := s.Evaluate(context.TODO(), enabledFlag, &flipt.EvaluationRequest{ + EntityId: "1", + FlagKey: "foo", + Context: map[string]string{ + "bar": "baz", + }, + }) + + assert.Error(t, err) + assert.EqualError(t, err, "parsing number from \"baz\"") + assert.False(t, resp.Match) + assert.Equal(t, flipt.EvaluationReason_ERROR_EVALUATION_REASON, resp.Reason) +} +func TestEvaluator_ErrorParsingDateTime(t *testing.T) { + var ( + store = &evaluationStoreMock{} + logger = zaptest.NewLogger(t) + s = NewEvaluator(logger, store) + ) + + store.On("GetEvaluationRules", mock.Anything, mock.Anything, "foo").Return( + []*storage.EvaluationRule{ + { + ID: "1", + FlagKey: "foo", + SegmentKey: "bar", + SegmentMatchType: flipt.MatchType_ALL_MATCH_TYPE, + Rank: 1, + Constraints: []storage.EvaluationConstraint{ + { + ID: "2", + Type: flipt.ComparisonType_DATETIME_COMPARISON_TYPE, + Property: "bar", + Operator: flipt.OpEQ, + Value: "boz", + }, + }, + }, + }, nil) + + resp, err := s.Evaluate(context.TODO(), enabledFlag, &flipt.EvaluationRequest{ + EntityId: "1", + FlagKey: "foo", + Context: map[string]string{ + "bar": "baz", + }, + }) + + assert.Error(t, err) + assert.EqualError(t, err, "parsing datetime from \"baz\"") + assert.False(t, resp.Match) + assert.Equal(t, flipt.EvaluationReason_ERROR_EVALUATION_REASON, resp.Reason) +} + func TestEvaluator_ErrorGettingDistributions(t *testing.T) { var ( store = &evaluationStoreMock{} @@ -1023,6 +1104,69 @@ func TestEvaluator_MatchAll_NoVariants_NoDistributions(t *testing.T) { } } +func TestEvaluator_DistributionNotMatched(t *testing.T) { + var ( + store = &evaluationStoreMock{} + logger = zaptest.NewLogger(t) + s = NewEvaluator(logger, store) + ) + + store.On("GetEvaluationRules", mock.Anything, mock.Anything, "foo").Return( + []*storage.EvaluationRule{ + { + ID: "1", + FlagKey: "foo", + SegmentKey: "bar", + SegmentMatchType: flipt.MatchType_ALL_MATCH_TYPE, + Rank: 0, + Constraints: []storage.EvaluationConstraint{ + // constraint: bar (string) == baz + { + ID: "2", + Type: flipt.ComparisonType_STRING_COMPARISON_TYPE, + Property: "bar", + Operator: flipt.OpEQ, + Value: "baz", + }, + // constraint: admin (bool) == true + { + ID: "3", + Type: flipt.ComparisonType_BOOLEAN_COMPARISON_TYPE, + Property: "admin", + Operator: flipt.OpTrue, + }, + }, + }, + }, nil) + + store.On("GetEvaluationDistributions", mock.Anything, "1").Return( + []*storage.EvaluationDistribution{ + { + ID: "4", + RuleID: "1", + VariantID: "5", + Rollout: 10, + VariantKey: "boz", + VariantAttachment: `{"key":"value"}`, + }, + }, nil) + + resp, err := s.Evaluate(context.TODO(), enabledFlag, &flipt.EvaluationRequest{ + FlagKey: "foo", + EntityId: "123", + Context: map[string]string{ + "bar": "baz", + "admin": "true", + }, + }) + + assert.NoError(t, err) + assert.NotNil(t, resp) + assert.Equal(t, "foo", resp.FlagKey) + + assert.False(t, resp.Match, "distribution not matched") +} + func TestEvaluator_MatchAll_SingleVariantDistribution(t *testing.T) { var ( store = &evaluationStoreMock{} diff --git a/internal/server/evaluation/server.go b/internal/server/evaluation/server.go index ab7ff8feb6..8012b31f1f 100644 --- a/internal/server/evaluation/server.go +++ b/internal/server/evaluation/server.go @@ -10,23 +10,19 @@ import ( "google.golang.org/grpc" ) -// Storer is the server side contract for communicating with the storage layer. +// Storer is the minimal abstraction for interacting with the storage layer for evaluation. type Storer interface { GetFlag(ctx context.Context, namespaceKey, key string) (*flipt.Flag, error) GetEvaluationRules(ctx context.Context, namespaceKey string, flagKey string) ([]*storage.EvaluationRule, error) GetEvaluationDistributions(ctx context.Context, ruleID string) ([]*storage.EvaluationDistribution, error) -} - -// MultiVariateEvaluator is an abstraction for evaluating a flag against a set of rules for multi-variate flags. -type MultiVariateEvaluator interface { - Evaluate(ctx context.Context, flag *flipt.Flag, r *flipt.EvaluationRequest) (*flipt.EvaluationResponse, error) + GetEvaluationRollouts(ctx context.Context, namespaceKey, flagKey string) ([]*storage.EvaluationRollout, error) } // Server serves the Flipt evaluate v2 gRPC Server. type Server struct { logger *zap.Logger store Storer - evaluator MultiVariateEvaluator + evaluator *Evaluator rpcEvalution.UnimplementedEvaluationServiceServer } diff --git a/internal/server/middleware/grpc/support_test.go b/internal/server/middleware/grpc/support_test.go index b427b03dec..35d04daeaa 100644 --- a/internal/server/middleware/grpc/support_test.go +++ b/internal/server/middleware/grpc/support_test.go @@ -240,6 +240,11 @@ func (m *storeMock) GetEvaluationDistributions(ctx context.Context, ruleID strin return args.Get(0).([]*storage.EvaluationDistribution), args.Error(1) } +func (m *storeMock) GetEvaluationRollouts(ctx context.Context, namespaceKey, flagKey string) ([]*storage.EvaluationRollout, error) { + args := m.Called(ctx, namespaceKey, flagKey) + return args.Get(0).([]*storage.EvaluationRollout), args.Error(1) +} + var _ storageauth.Store = &authStoreMock{} type authStoreMock struct { diff --git a/internal/server/server.go b/internal/server/server.go index af954d1ad4..6cd7b493cc 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -22,7 +22,7 @@ type Server struct { logger *zap.Logger store storage.Store flipt.UnimplementedFliptServer - evaluator evaluation.MultiVariateEvaluator + evaluator MultiVariateEvaluator } // New creates a new Server diff --git a/internal/server/support_test.go b/internal/server/support_test.go index 5cc11981ad..cf64e3079b 100644 --- a/internal/server/support_test.go +++ b/internal/server/support_test.go @@ -232,3 +232,8 @@ func (m *storeMock) GetEvaluationDistributions(ctx context.Context, ruleID strin args := m.Called(ctx, ruleID) return args.Get(0).([]*storage.EvaluationDistribution), args.Error(1) } + +func (m *storeMock) GetEvaluationRollouts(ctx context.Context, namespaceKey, flagKey string) ([]*storage.EvaluationRollout, error) { + args := m.Called(ctx, namespaceKey, flagKey) + return args.Get(0).([]*storage.EvaluationRollout), args.Error(1) +} diff --git a/internal/storage/fs/snapshot.go b/internal/storage/fs/snapshot.go index 2c9c6eace0..83edbd6f0d 100644 --- a/internal/storage/fs/snapshot.go +++ b/internal/storage/fs/snapshot.go @@ -625,6 +625,10 @@ func (ss *storeSnapshot) GetEvaluationDistributions(ctx context.Context, ruleID return dists, nil } +func (ss *storeSnapshot) GetEvaluationRollouts(ctx context.Context, namespaceKey, flagKey string) ([]*storage.EvaluationRollout, error) { + panic("not implemented") +} + func (ss *storeSnapshot) GetRollout(ctx context.Context, namespaceKey, id string) (*flipt.Rollout, error) { panic("not implemented") } diff --git a/internal/storage/sql/common/evaluation.go b/internal/storage/sql/common/evaluation.go index 91877f8794..a040cc897e 100644 --- a/internal/storage/sql/common/evaluation.go +++ b/internal/storage/sql/common/evaluation.go @@ -160,3 +160,7 @@ func (s *Store) GetEvaluationDistributions(ctx context.Context, ruleID string) ( return distributions, nil } + +func (s *Store) GetEvaluationRollouts(ctx context.Context, namespaceKey, flagKey string) ([]*storage.EvaluationRollout, error) { + return []*storage.EvaluationRollout{}, nil +} diff --git a/internal/storage/storage.go b/internal/storage/storage.go index 9f9b6b8574..6b36977920 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -27,6 +27,29 @@ type EvaluationRule struct { Constraints []EvaluationConstraint } +// EvaluationRollout represents a rollout in the form that helps with evaluation. +type EvaluationRollout struct { + NamespaceKey string + RolloutType flipt.RolloutType + Rank int32 + Percentage *RolloutPercentage + Segment *RolloutSegment +} + +// RolloutPercentage represents Percentage(s) for use in evaluation. +type RolloutPercentage struct { + Percentage float32 + Value bool +} + +// RolloutSegment represents Segment(s) for use in evaluation. +type RolloutSegment struct { + SegmentKey string + SegmentMatchType flipt.MatchType + Value bool + Constraints []EvaluationConstraint +} + // EvaluationConstraint represents a segment constraint that is used for evaluation type EvaluationConstraint struct { ID string @@ -147,6 +170,7 @@ type EvaluationStore interface { // Note: Rules MUST be returned in order by Rank GetEvaluationRules(ctx context.Context, namespaceKey, flagKey string) ([]*EvaluationRule, error) GetEvaluationDistributions(ctx context.Context, ruleID string) ([]*EvaluationDistribution, error) + GetEvaluationRollouts(ctx context.Context, namespaceKey, flagKey string) ([]*EvaluationRollout, error) } // NamespaceStore stores and retrieves namespaces diff --git a/rpc/flipt/evaluation/evaluation.go b/rpc/flipt/evaluation/evaluation.go index 7720987308..df144c79ef 100644 --- a/rpc/flipt/evaluation/evaluation.go +++ b/rpc/flipt/evaluation/evaluation.go @@ -45,6 +45,14 @@ func (x *VariantEvaluationResponse) SetRequestIDIfNotBlank(id string) string { return x.RequestId } +func (x *BooleanEvaluationResponse) SetRequestIDIfNotBlank(id string) string { + if x.RequestId == "" { + x.RequestId = id + } + + return x.RequestId +} + // SetRequestIDIfNotBlank attempts to set the provided ID on the instance // If the ID was blank, it returns the ID provided to this call. // If the ID was not blank, it returns the ID found on the instance. @@ -106,6 +114,11 @@ func (x *VariantEvaluationResponse) SetTimestamps(start, end time.Time) { x.RequestDurationMillis = float64(end.Sub(start)) / float64(time.Millisecond) } +func (x *BooleanEvaluationResponse) SetTimestamps(start, end time.Time) { + x.Timestamp = timestamppb.New(end) + x.RequestDurationMillis = float64(end.Sub(start)) / float64(time.Millisecond) +} + // SetTimestamps records the start and end times on the target instance. func (x *BatchEvaluationResponse) SetTimestamps(start, end time.Time) { x.RequestDurationMillis = float64(end.Sub(start)) / float64(time.Millisecond)