Skip to content

Commit 1c9172d

Browse files
committed
add holdouts service and logic
1 parent 92b83d2 commit 1c9172d

File tree

9 files changed

+199
-3
lines changed

9 files changed

+199
-3
lines changed

pkg/cmab/service_test.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,11 @@ func (m *MockProjectConfig) GetRegion() string {
230230
return args.String(0)
231231
}
232232

233+
func (m *MockProjectConfig) GetHoldoutsForFlag(featureKey string) []entities.Holdout {
234+
args := m.Called(featureKey)
235+
return args.Get(0).([]entities.Holdout)
236+
}
237+
233238
type CmabServiceTestSuite struct {
234239
suite.Suite
235240
mockClient *MockCmabClient

pkg/config/datafileprojectconfig/config.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,13 @@ func (c DatafileProjectConfig) GetRegion() string {
281281
return c.region
282282
}
283283

284+
// GetHoldoutsForFlag returns all holdouts applicable to the given feature flag
285+
// TODO: Implementation will be added in holdout parsing PR
286+
func (c DatafileProjectConfig) GetHoldoutsForFlag(featureKey string) []entities.Holdout {
287+
// Stub implementation - will be replaced with actual holdout logic
288+
return []entities.Holdout{}
289+
}
290+
284291
// NewDatafileProjectConfig initializes a new datafile from a json byte array using the default JSON datafile parser
285292
func NewDatafileProjectConfig(jsonDatafile []byte, logger logging.OptimizelyLogProducer) (*DatafileProjectConfig, error) {
286293
datafile, err := Parse(jsonDatafile)

pkg/config/interface.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ type ProjectConfig interface {
5656
GetAttributes() []entities.Attribute
5757
GetFlagVariationsMap() map[string][]entities.Variation
5858
GetRegion() string
59+
GetHoldoutsForFlag(featureKey string) []entities.Holdout
5960
}
6061

6162
// ProjectConfigManager maintains an instance of the ProjectConfig

pkg/decision/composite_feature_service.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ func NewCompositeFeatureService(sdkKey string, compositeExperimentService Experi
3434
return &CompositeFeatureService{
3535
logger: logging.GetLogger(sdkKey, "CompositeFeatureService"),
3636
featureServices: []FeatureService{
37+
NewHoldoutService(sdkKey),
3738
NewFeatureExperimentService(logging.GetLogger(sdkKey, "FeatureExperimentService"), compositeExperimentService),
3839
NewRolloutService(sdkKey),
3940
},

pkg/decision/composite_feature_service_test.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -202,9 +202,10 @@ func (s *CompositeFeatureServiceTestSuite) TestNewCompositeFeatureService() {
202202
// Assert that the service is instantiated with the correct child services in the right order
203203
compositeExperimentService := NewCompositeExperimentService("")
204204
compositeFeatureService := NewCompositeFeatureService("", compositeExperimentService)
205-
s.Equal(2, len(compositeFeatureService.featureServices))
206-
s.IsType(&FeatureExperimentService{compositeExperimentService: compositeExperimentService}, compositeFeatureService.featureServices[0])
207-
s.IsType(&RolloutService{}, compositeFeatureService.featureServices[1])
205+
s.Equal(3, len(compositeFeatureService.featureServices))
206+
s.IsType(&HoldoutService{}, compositeFeatureService.featureServices[0])
207+
s.IsType(&FeatureExperimentService{compositeExperimentService: compositeExperimentService}, compositeFeatureService.featureServices[1])
208+
s.IsType(&RolloutService{}, compositeFeatureService.featureServices[2])
208209
}
209210

210211
func TestCompositeFeatureTestSuite(t *testing.T) {

pkg/decision/entities.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,8 @@ const (
5555
Rollout Source = "rollout"
5656
// FeatureTest - the decision came from a feature test
5757
FeatureTest Source = "feature-test"
58+
// Holdout - the decision came from a holdout
59+
Holdout Source = "holdout"
5860
)
5961

6062
// Decision contains base information about a decision

pkg/decision/evaluator/audience_evaluator_test.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,11 @@ func (m *MockProjectConfig) GetRegion() string {
200200
return args.String(0)
201201
}
202202

203+
func (m *MockProjectConfig) GetHoldoutsForFlag(featureKey string) []entities.Holdout {
204+
args := m.Called(featureKey)
205+
return args.Get(0).([]entities.Holdout)
206+
}
207+
203208
// MockLogger is a mock implementation of OptimizelyLogProducer
204209
// (This declaration has been removed to resolve the redeclaration error)
205210

pkg/decision/holdout_service.go

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
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 decision //
18+
package decision
19+
20+
import (
21+
"fmt"
22+
23+
"github.com/optimizely/go-sdk/v2/pkg/config"
24+
"github.com/optimizely/go-sdk/v2/pkg/decide"
25+
"github.com/optimizely/go-sdk/v2/pkg/decision/bucketer"
26+
"github.com/optimizely/go-sdk/v2/pkg/decision/evaluator"
27+
"github.com/optimizely/go-sdk/v2/pkg/entities"
28+
"github.com/optimizely/go-sdk/v2/pkg/logging"
29+
)
30+
31+
// HoldoutService evaluates holdout groups for feature flags
32+
type HoldoutService struct {
33+
audienceTreeEvaluator evaluator.TreeEvaluator
34+
bucketer bucketer.ExperimentBucketer
35+
logger logging.OptimizelyLogProducer
36+
}
37+
38+
// NewHoldoutService returns a new instance of the HoldoutService
39+
func NewHoldoutService(sdkKey string) *HoldoutService {
40+
logger := logging.GetLogger(sdkKey, "HoldoutService")
41+
return &HoldoutService{
42+
audienceTreeEvaluator: evaluator.NewMixedTreeEvaluator(logger),
43+
bucketer: *bucketer.NewMurmurhashExperimentBucketer(logger, bucketer.DefaultHashSeed),
44+
logger: logger,
45+
}
46+
}
47+
48+
// GetDecision returns a decision for holdouts associated with the feature
49+
func (h HoldoutService) GetDecision(decisionContext FeatureDecisionContext, userContext entities.UserContext, options *decide.Options) (FeatureDecision, decide.DecisionReasons, error) {
50+
feature := decisionContext.Feature
51+
reasons := decide.NewDecisionReasons(options)
52+
53+
holdouts := decisionContext.ProjectConfig.GetHoldoutsForFlag(feature.Key)
54+
55+
for _, holdout := range holdouts {
56+
h.logger.Debug(fmt.Sprintf("Evaluating holdout %s for feature %s", holdout.Key, feature.Key))
57+
58+
// Check if holdout is running
59+
if holdout.Status != entities.HoldoutStatusRunning {
60+
reason := reasons.AddInfo("Holdout %s is not running.", holdout.Key)
61+
h.logger.Info(reason)
62+
continue
63+
}
64+
65+
// Check audience conditions
66+
inAudience := h.checkIfUserInHoldoutAudience(&holdout, userContext, decisionContext.ProjectConfig, options)
67+
reasons.Append(inAudience.reasons)
68+
69+
if !inAudience.result {
70+
reason := reasons.AddInfo("User %s does not meet conditions for holdout %s.", userContext.ID, holdout.Key)
71+
h.logger.Info(reason)
72+
continue
73+
}
74+
75+
reason := reasons.AddInfo("User %s meets conditions for holdout %s.", userContext.ID, holdout.Key)
76+
h.logger.Info(reason)
77+
78+
// Get bucketing ID
79+
bucketingID, err := userContext.GetBucketingID()
80+
if err != nil {
81+
errorMessage := reasons.AddInfo("Error computing bucketing ID for holdout %q: %q", holdout.Key, err.Error())
82+
h.logger.Debug(errorMessage)
83+
}
84+
85+
if bucketingID != userContext.ID {
86+
h.logger.Debug(fmt.Sprintf("Using bucketing ID: %q for user %q", bucketingID, userContext.ID))
87+
}
88+
89+
// Convert holdout to experiment structure for bucketing
90+
experimentForBucketing := entities.Experiment{
91+
ID: holdout.ID,
92+
Key: holdout.Key,
93+
Variations: holdout.Variations,
94+
TrafficAllocation: holdout.TrafficAllocation,
95+
AudienceIds: holdout.AudienceIds,
96+
AudienceConditions: holdout.AudienceConditions,
97+
AudienceConditionTree: holdout.AudienceConditionTree,
98+
}
99+
100+
// Bucket user into holdout variation
101+
variation, _, _ := h.bucketer.Bucket(bucketingID, experimentForBucketing, entities.Group{})
102+
103+
if variation != nil {
104+
reason := reasons.AddInfo("User %s is in variation %s of holdout %s.", userContext.ID, variation.Key, holdout.Key)
105+
h.logger.Info(reason)
106+
107+
featureDecision := FeatureDecision{
108+
Experiment: experimentForBucketing,
109+
Variation: variation,
110+
Source: Holdout,
111+
}
112+
return featureDecision, reasons, nil
113+
}
114+
115+
reason = reasons.AddInfo("User %s is in no holdout variation.", userContext.ID)
116+
h.logger.Info(reason)
117+
}
118+
119+
return FeatureDecision{}, reasons, nil
120+
}
121+
122+
// checkIfUserInHoldoutAudience evaluates if user meets holdout audience conditions
123+
func (h HoldoutService) checkIfUserInHoldoutAudience(holdout *entities.Holdout, userContext entities.UserContext, projectConfig config.ProjectConfig, options *decide.Options) decisionResult {
124+
decisionReasons := decide.NewDecisionReasons(options)
125+
126+
if holdout == nil {
127+
logMessage := decisionReasons.AddInfo("Holdout is nil, defaulting to false")
128+
h.logger.Debug(logMessage)
129+
return decisionResult{result: false, reasons: decisionReasons}
130+
}
131+
132+
if holdout.AudienceConditionTree != nil {
133+
condTreeParams := entities.NewTreeParameters(&userContext, projectConfig.GetAudienceMap())
134+
h.logger.Debug(fmt.Sprintf("Evaluating audiences for holdout %q.", holdout.Key))
135+
136+
evalResult, _, audienceReasons := h.audienceTreeEvaluator.Evaluate(holdout.AudienceConditionTree, condTreeParams, options)
137+
decisionReasons.Append(audienceReasons)
138+
139+
logMessage := decisionReasons.AddInfo("Audiences for holdout %s collectively evaluated to %v.", holdout.Key, evalResult)
140+
h.logger.Debug(logMessage)
141+
142+
return decisionResult{result: evalResult, reasons: decisionReasons}
143+
}
144+
145+
logMessage := decisionReasons.AddInfo("Audiences for holdout %s collectively evaluated to true.", holdout.Key)
146+
h.logger.Debug(logMessage)
147+
return decisionResult{result: true, reasons: decisionReasons}
148+
}
149+
150+
// decisionResult is a helper struct to return both result and reasons
151+
type decisionResult struct {
152+
result bool
153+
reasons decide.DecisionReasons
154+
}

pkg/entities/experiment.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,3 +59,23 @@ type VariationVariable struct {
5959
ID string
6060
Value string
6161
}
62+
63+
// HoldoutStatus represents the status of a holdout
64+
type HoldoutStatus string
65+
66+
const (
67+
// HoldoutStatusRunning - the holdout status is running
68+
HoldoutStatusRunning HoldoutStatus = "Running"
69+
)
70+
71+
// Holdout represents a holdout that can be applied to feature flags
72+
type Holdout struct {
73+
ID string
74+
Key string
75+
Status HoldoutStatus
76+
AudienceIds []string
77+
AudienceConditions interface{}
78+
Variations map[string]Variation // keyed by variation ID
79+
TrafficAllocation []Range
80+
AudienceConditionTree *TreeNode
81+
}

0 commit comments

Comments
 (0)