Skip to content
Open
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
10 changes: 10 additions & 0 deletions pkg/cmab/service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,16 @@ func (m *MockProjectConfig) GetHoldoutsForFlag(featureKey string) []entities.Hol
return args.Get(0).([]entities.Holdout)
}

func (m *MockProjectConfig) GetGlobalHoldouts() []entities.Holdout {
args := m.Called()
return args.Get(0).([]entities.Holdout)
}

func (m *MockProjectConfig) GetHoldoutsForRule(ruleID string) []entities.Holdout {
args := m.Called(ruleID)
return args.Get(0).([]entities.Holdout)
}

type CmabServiceTestSuite struct {
suite.Suite
mockClient *MockCmabClient
Expand Down
23 changes: 17 additions & 6 deletions pkg/config/datafileprojectconfig/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,10 @@ type DatafileProjectConfig struct {
flagVariationsMap map[string][]entities.Variation
holdouts []entities.Holdout
holdoutIDMap map[string]entities.Holdout
flagHoldoutsMap map[string][]entities.Holdout
// ruleHoldoutsMap maps rule IDs to local holdouts targeting those rules
ruleHoldoutsMap map[string][]entities.Holdout
// globalHoldouts holds only global holdouts (IncludedRules == nil)
globalHoldouts []entities.Holdout
}

// GetDatafile returns a string representation of the environment's datafile
Expand Down Expand Up @@ -284,9 +287,16 @@ func (c DatafileProjectConfig) GetRegion() string {
return c.region
}

// GetHoldoutsForFlag returns all holdouts applicable to the given feature flag
func (c DatafileProjectConfig) GetHoldoutsForFlag(featureKey string) []entities.Holdout {
if holdouts, exists := c.flagHoldoutsMap[featureKey]; exists {
// GetGlobalHoldouts returns all global holdouts (those with IncludedRules == nil).
// These are evaluated at flag level, before any per-rule evaluation.
func (c DatafileProjectConfig) GetGlobalHoldouts() []entities.Holdout {
return c.globalHoldouts
}

// GetHoldoutsForRule returns all local holdouts that target the given rule ID.
// These are evaluated per-rule, after forced decisions, before audience/traffic checks.
func (c DatafileProjectConfig) GetHoldoutsForRule(ruleID string) []entities.Holdout {
if holdouts, exists := c.ruleHoldoutsMap[ruleID]; exists {
return holdouts
}
return []entities.Holdout{}
Expand Down Expand Up @@ -338,7 +348,7 @@ func NewDatafileProjectConfig(jsonDatafile []byte, logger logging.OptimizelyLogP

audienceMap, audienceSegmentList := mappers.MapAudiences(append(datafile.TypedAudiences, datafile.Audiences...))
flagVariationsMap := mappers.MapFlagVariations(featureMap)
holdouts, holdoutIDMap, flagHoldoutsMap := mappers.MapHoldouts(datafile.Holdouts, featureMap)
holdouts, holdoutIDMap, globalHoldouts, ruleHoldoutsMap := mappers.MapHoldouts(datafile.Holdouts)

attributeKeyMap := make(map[string]entities.Attribute)
attributeIDToKeyMap := make(map[string]string)
Expand Down Expand Up @@ -383,7 +393,8 @@ func NewDatafileProjectConfig(jsonDatafile []byte, logger logging.OptimizelyLogP
region: region,
holdouts: holdouts,
holdoutIDMap: holdoutIDMap,
flagHoldoutsMap: flagHoldoutsMap,
ruleHoldoutsMap: ruleHoldoutsMap,
globalHoldouts: globalHoldouts,
}

logger.Info("Datafile is valid.")
Expand Down
44 changes: 11 additions & 33 deletions pkg/config/datafileprojectconfig/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -729,8 +729,8 @@ func TestGetAttributeByKeyWithDirectMapping(t *testing.T) {
assert.Equal(t, attribute, actual)
}

func TestGetHoldoutsForFlagWithHoldouts(t *testing.T) {
flagKey := "test_flag"
func TestGetHoldoutsForRuleWithHoldouts(t *testing.T) {
ruleID := "test_rule_id"
holdout1 := entities.Holdout{
ID: "holdout_1",
Key: "test_holdout_1",
Expand All @@ -742,50 +742,28 @@ func TestGetHoldoutsForFlagWithHoldouts(t *testing.T) {
Status: entities.HoldoutStatusRunning,
}

flagHoldoutsMap := make(map[string][]entities.Holdout)
flagHoldoutsMap[flagKey] = []entities.Holdout{holdout1, holdout2}
ruleHoldoutsMap := make(map[string][]entities.Holdout)
ruleHoldoutsMap[ruleID] = []entities.Holdout{holdout1, holdout2}

config := &DatafileProjectConfig{
flagHoldoutsMap: flagHoldoutsMap,
ruleHoldoutsMap: ruleHoldoutsMap,
}

actual := config.GetHoldoutsForFlag(flagKey)
actual := config.GetHoldoutsForRule(ruleID)
assert.Len(t, actual, 2)
assert.Equal(t, holdout1, actual[0])
assert.Equal(t, holdout2, actual[1])
}

func TestGetHoldoutsForFlagWithNoHoldouts(t *testing.T) {
flagKey := "test_flag"
flagHoldoutsMap := make(map[string][]entities.Holdout)
func TestGetHoldoutsForRuleWithNoHoldouts(t *testing.T) {
ruleID := "test_rule_id"
ruleHoldoutsMap := make(map[string][]entities.Holdout)

config := &DatafileProjectConfig{
flagHoldoutsMap: flagHoldoutsMap,
ruleHoldoutsMap: ruleHoldoutsMap,
}

actual := config.GetHoldoutsForFlag(flagKey)
assert.Len(t, actual, 0)
assert.Equal(t, []entities.Holdout{}, actual)
}

func TestGetHoldoutsForFlagWithDifferentFlag(t *testing.T) {
flagKey := "test_flag"
otherFlagKey := "other_flag"
holdout := entities.Holdout{
ID: "holdout_1",
Key: "test_holdout_1",
Status: entities.HoldoutStatusRunning,
}

flagHoldoutsMap := make(map[string][]entities.Holdout)
flagHoldoutsMap[otherFlagKey] = []entities.Holdout{holdout}

config := &DatafileProjectConfig{
flagHoldoutsMap: flagHoldoutsMap,
}

// Request different flag - should return empty
actual := config.GetHoldoutsForFlag(flagKey)
actual := config.GetHoldoutsForRule(ruleID)
assert.Len(t, actual, 0)
assert.Equal(t, []entities.Holdout{}, actual)
}
4 changes: 4 additions & 0 deletions pkg/config/datafileprojectconfig/entities/entities.go
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,10 @@ type Holdout struct {
AudienceConditions interface{} `json:"audienceConditions"`
Variations []Variation `json:"variations"`
TrafficAllocation []TrafficAllocation `json:"trafficAllocation"`
// IncludedRules is optional. nil = global holdout (applies to all rules across all flags).
// Non-nil array = local holdout (applies only to the specified rule IDs).
Comment thread
Mat001 marked this conversation as resolved.
// An empty non-nil array means a local holdout that targets no rules.
IncludedRules *[]string `json:"includedRules,omitempty"`
}

// Integration represents a integration from the Optimizely datafile
Expand Down
31 changes: 17 additions & 14 deletions pkg/config/datafileprojectconfig/mappers/holdout.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,18 +22,19 @@ import (
"github.com/optimizely/go-sdk/v2/pkg/entities"
)

// MapHoldouts maps the raw datafile holdout entities to SDK Holdout entities
// All running holdouts apply to all flags
func MapHoldouts(holdouts []datafileEntities.Holdout, featureMap map[string]entities.Feature) (
// MapHoldouts maps the raw datafile holdout entities to SDK Holdout entities.
// Global holdouts (IncludedRules == nil) are returned in globalHoldouts for flag-level evaluation.
// Local holdouts (IncludedRules != nil) are indexed by rule ID in ruleHoldoutsMap.
func MapHoldouts(holdouts []datafileEntities.Holdout) (
holdoutList []entities.Holdout,
holdoutIDMap map[string]entities.Holdout,
flagHoldoutsMap map[string][]entities.Holdout,
globalHoldouts []entities.Holdout,
ruleHoldoutsMap map[string][]entities.Holdout,
) {
holdoutList = []entities.Holdout{}
holdoutIDMap = make(map[string]entities.Holdout)
flagHoldoutsMap = make(map[string][]entities.Holdout)

runningHoldouts := []entities.Holdout{}
globalHoldouts = []entities.Holdout{}
ruleHoldoutsMap = make(map[string][]entities.Holdout)

for _, holdout := range holdouts {
// Only process running holdouts
Expand All @@ -44,17 +45,18 @@ func MapHoldouts(holdouts []datafileEntities.Holdout, featureMap map[string]enti
mappedHoldout := mapHoldout(holdout)
holdoutList = append(holdoutList, mappedHoldout)
holdoutIDMap[holdout.ID] = mappedHoldout
runningHoldouts = append(runningHoldouts, mappedHoldout)
}

// All running holdouts apply to all flags
for _, feature := range featureMap {
if len(runningHoldouts) > 0 {
flagHoldoutsMap[feature.Key] = runningHoldouts
if mappedHoldout.IsGlobal() {
globalHoldouts = append(globalHoldouts, mappedHoldout)
} else {
// Local holdout: applies only to the specified rule IDs
for _, ruleID := range *mappedHoldout.IncludedRules {
ruleHoldoutsMap[ruleID] = append(ruleHoldoutsMap[ruleID], mappedHoldout)
}
}
}

return holdoutList, holdoutIDMap, flagHoldoutsMap
return holdoutList, holdoutIDMap, globalHoldouts, ruleHoldoutsMap
}

func mapHoldout(datafileHoldout datafileEntities.Holdout) entities.Holdout {
Expand Down Expand Up @@ -107,5 +109,6 @@ func mapHoldout(datafileHoldout datafileEntities.Holdout) entities.Holdout {
Variations: variations,
TrafficAllocation: trafficAllocation,
AudienceConditionTree: audienceConditionTree,
IncludedRules: datafileHoldout.IncludedRules,
}
}
Loading
Loading