Skip to content

Commit 79642d7

Browse files
Mat001claude
andcommitted
feat: Complete holdout implementation with parsing, mapping, and tests
- Implemented holdout parsing from datafile with global/specific/exclusion logic - Added MapHoldouts() to process holdout relationships with feature flags - Implemented GetHoldoutsForFlag() to retrieve applicable holdouts per flag - Fixed bucketer initialization to use pointer (interface value) - Created comprehensive unit tests for HoldoutService (11 test cases) - Added integration test with real bucketer and evaluator - Added GetHoldoutsForFlag() mock method to helpers_test.go All tests passing (decision and config packages) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]>
1 parent 8c19ff7 commit 79642d7

File tree

6 files changed

+587
-3
lines changed

6 files changed

+587
-3
lines changed

pkg/config/datafileprojectconfig/config.go

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,9 @@ type DatafileProjectConfig struct {
6060
region string
6161

6262
flagVariationsMap map[string][]entities.Variation
63+
holdouts []entities.Holdout
64+
holdoutIDMap map[string]entities.Holdout
65+
flagHoldoutsMap map[string][]entities.Holdout
6366
}
6467

6568
// GetDatafile returns a string representation of the environment's datafile
@@ -282,9 +285,10 @@ func (c DatafileProjectConfig) GetRegion() string {
282285
}
283286

284287
// GetHoldoutsForFlag returns all holdouts applicable to the given feature flag
285-
// TODO: Implementation will be added in holdout parsing PR
286288
func (c DatafileProjectConfig) GetHoldoutsForFlag(featureKey string) []entities.Holdout {
287-
// Stub implementation - will be replaced with actual holdout logic
289+
if holdouts, exists := c.flagHoldoutsMap[featureKey]; exists {
290+
return holdouts
291+
}
288292
return []entities.Holdout{}
289293
}
290294

@@ -330,6 +334,7 @@ func NewDatafileProjectConfig(jsonDatafile []byte, logger logging.OptimizelyLogP
330334
featureMap := mappers.MapFeatures(datafile.FeatureFlags, rolloutMap, experimentIDMap)
331335
audienceMap, audienceSegmentList := mappers.MapAudiences(append(datafile.TypedAudiences, datafile.Audiences...))
332336
flagVariationsMap := mappers.MapFlagVariations(featureMap)
337+
holdouts, holdoutIDMap, flagHoldoutsMap := mappers.MapHoldouts(datafile.Holdouts, featureMap)
333338

334339
attributeKeyMap := make(map[string]entities.Attribute)
335340
attributeIDToKeyMap := make(map[string]string)
@@ -372,6 +377,9 @@ func NewDatafileProjectConfig(jsonDatafile []byte, logger logging.OptimizelyLogP
372377
attributeKeyMap: attributeKeyMap,
373378
attributeIDToKeyMap: attributeIDToKeyMap,
374379
region: region,
380+
holdouts: holdouts,
381+
holdoutIDMap: holdoutIDMap,
382+
flagHoldoutsMap: flagHoldoutsMap,
375383
}
376384

377385
logger.Info("Datafile is valid.")

pkg/config/datafileprojectconfig/entities/entities.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,19 @@ type Rollout struct {
113113
Experiments []Experiment `json:"experiments"`
114114
}
115115

116+
// Holdout represents a holdout from the Optimizely datafile
117+
type Holdout struct {
118+
ID string `json:"id"`
119+
Key string `json:"key"`
120+
Status string `json:"status"`
121+
AudienceIds []string `json:"audienceIds"`
122+
AudienceConditions interface{} `json:"audienceConditions"`
123+
Variations []Variation `json:"variations"`
124+
TrafficAllocation []TrafficAllocation `json:"trafficAllocation"`
125+
IncludedFlags []string `json:"includedFlags,omitempty"`
126+
ExcludedFlags []string `json:"excludedFlags,omitempty"`
127+
}
128+
116129
// Integration represents a integration from the Optimizely datafile
117130
type Integration struct {
118131
Key *string `json:"key"`
@@ -129,6 +142,7 @@ type Datafile struct {
129142
FeatureFlags []FeatureFlag `json:"featureFlags"`
130143
Events []Event `json:"events"`
131144
Rollouts []Rollout `json:"rollouts"`
145+
Holdouts []Holdout `json:"holdouts,omitempty"`
132146
Integrations []Integration `json:"integrations"`
133147
TypedAudiences []Audience `json:"typedAudiences"`
134148
Variables []string `json:"variables"`
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
/****************************************************************************
2+
* Copyright 2025, Optimizely, Inc. and contributors *
3+
* *
4+
* Licensed under the Apache License, Version 2.0 (the "License"); *
5+
* you may not use this file except in compliance with the License. *
6+
* You may obtain a copy of the License at *
7+
* *
8+
* http://www.apache.org/licenses/LICENSE-2.0 *
9+
* *
10+
* Unless required by applicable law or agreed to in writing, software *
11+
* distributed under the License is distributed on an "AS IS" BASIS, *
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. *
13+
* See the License for the specific language governing permissions and *
14+
* limitations under the License. *
15+
***************************************************************************/
16+
17+
// Package mappers ...
18+
package mappers
19+
20+
import (
21+
datafileEntities "github.com/optimizely/go-sdk/v2/pkg/config/datafileprojectconfig/entities"
22+
"github.com/optimizely/go-sdk/v2/pkg/entities"
23+
)
24+
25+
// MapHoldouts maps the raw datafile holdout entities to SDK Holdout entities
26+
// and organizes them by flag relationships
27+
func MapHoldouts(holdouts []datafileEntities.Holdout, featureMap map[string]entities.Feature) (
28+
holdoutList []entities.Holdout,
29+
holdoutIDMap map[string]entities.Holdout,
30+
flagHoldoutsMap map[string][]entities.Holdout,
31+
) {
32+
holdoutList = []entities.Holdout{}
33+
holdoutIDMap = make(map[string]entities.Holdout)
34+
flagHoldoutsMap = make(map[string][]entities.Holdout)
35+
36+
globalHoldouts := []entities.Holdout{}
37+
includedHoldouts := make(map[string][]entities.Holdout)
38+
excludedHoldouts := make(map[string][]entities.Holdout)
39+
40+
for _, holdout := range holdouts {
41+
// Only process running holdouts
42+
if holdout.Status != string(entities.HoldoutStatusRunning) {
43+
continue
44+
}
45+
46+
mappedHoldout := mapHoldout(holdout)
47+
holdoutList = append(holdoutList, mappedHoldout)
48+
holdoutIDMap[holdout.ID] = mappedHoldout
49+
50+
// Classify holdout by flag relationships
51+
if len(holdout.IncludedFlags) == 0 {
52+
// Global holdout - applies to all flags except excluded
53+
globalHoldouts = append(globalHoldouts, mappedHoldout)
54+
55+
// Track exclusions
56+
for _, flagID := range holdout.ExcludedFlags {
57+
excludedHoldouts[flagID] = append(excludedHoldouts[flagID], mappedHoldout)
58+
}
59+
} else {
60+
// Specific holdout - applies only to included flags
61+
for _, flagID := range holdout.IncludedFlags {
62+
includedHoldouts[flagID] = append(includedHoldouts[flagID], mappedHoldout)
63+
}
64+
}
65+
}
66+
67+
// Build flagHoldoutsMap by combining global and specific holdouts
68+
for _, feature := range featureMap {
69+
flagKey := feature.Key
70+
flagID := feature.ID
71+
applicableHoldouts := []entities.Holdout{}
72+
73+
// Add specifically included holdouts
74+
if included, exists := includedHoldouts[flagID]; exists {
75+
applicableHoldouts = append(applicableHoldouts, included...)
76+
}
77+
78+
// Add global holdouts (if not excluded)
79+
isExcluded := false
80+
if _, exists := excludedHoldouts[flagID]; exists {
81+
isExcluded = true
82+
}
83+
84+
if !isExcluded {
85+
applicableHoldouts = append(applicableHoldouts, globalHoldouts...)
86+
}
87+
88+
if len(applicableHoldouts) > 0 {
89+
flagHoldoutsMap[flagKey] = applicableHoldouts
90+
}
91+
}
92+
93+
return holdoutList, holdoutIDMap, flagHoldoutsMap
94+
}
95+
96+
func mapHoldout(datafileHoldout datafileEntities.Holdout) entities.Holdout {
97+
var audienceConditionTree *entities.TreeNode
98+
var err error
99+
100+
// Build audience condition tree similar to experiments
101+
if datafileHoldout.AudienceConditions == nil && len(datafileHoldout.AudienceIds) > 0 {
102+
audienceConditionTree, err = buildAudienceConditionTree(datafileHoldout.AudienceIds)
103+
} else {
104+
switch audienceConditions := datafileHoldout.AudienceConditions.(type) {
105+
case []interface{}:
106+
if len(audienceConditions) > 0 {
107+
audienceConditionTree, err = buildAudienceConditionTree(audienceConditions)
108+
}
109+
case string:
110+
if audienceConditions != "" {
111+
audienceConditionTree, err = buildAudienceConditionTree([]string{audienceConditions})
112+
}
113+
default:
114+
}
115+
}
116+
if err != nil {
117+
// @TODO: handle error
118+
func() {}() // cheat the linters
119+
}
120+
121+
// Map variations
122+
variations := make(map[string]entities.Variation)
123+
for _, datafileVariation := range datafileHoldout.Variations {
124+
variation := mapVariation(datafileVariation)
125+
variations[variation.ID] = variation
126+
}
127+
128+
// Map traffic allocations
129+
trafficAllocation := make([]entities.Range, len(datafileHoldout.TrafficAllocation))
130+
for i, allocation := range datafileHoldout.TrafficAllocation {
131+
trafficAllocation[i] = entities.Range{
132+
EntityID: allocation.EntityID,
133+
EndOfRange: allocation.EndOfRange,
134+
}
135+
}
136+
137+
return entities.Holdout{
138+
ID: datafileHoldout.ID,
139+
Key: datafileHoldout.Key,
140+
Status: entities.HoldoutStatus(datafileHoldout.Status),
141+
AudienceIds: datafileHoldout.AudienceIds,
142+
AudienceConditions: datafileHoldout.AudienceConditions,
143+
Variations: variations,
144+
TrafficAllocation: trafficAllocation,
145+
AudienceConditionTree: audienceConditionTree,
146+
}
147+
}

pkg/decision/helpers_test.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,11 @@ func (c *mockProjectConfig) GetFlagVariationsMap() map[string][]entities.Variati
5959
return args.Get(0).(map[string][]entities.Variation)
6060
}
6161

62+
func (c *mockProjectConfig) GetHoldoutsForFlag(featureKey string) []entities.Holdout {
63+
args := c.Called(featureKey)
64+
return args.Get(0).([]entities.Holdout)
65+
}
66+
6267
type MockExperimentDecisionService struct {
6368
mock.Mock
6469
}

pkg/decision/holdout_service.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ func NewHoldoutService(sdkKey string) *HoldoutService {
4040
logger := logging.GetLogger(sdkKey, "HoldoutService")
4141
return &HoldoutService{
4242
audienceTreeEvaluator: evaluator.NewMixedTreeEvaluator(logger),
43-
bucketer: *bucketer.NewMurmurhashExperimentBucketer(logger, bucketer.DefaultHashSeed),
43+
bucketer: bucketer.NewMurmurhashExperimentBucketer(logger, bucketer.DefaultHashSeed),
4444
logger: logger,
4545
}
4646
}

0 commit comments

Comments
 (0)