Skip to content

Commit d055117

Browse files
committed
[FSSDK-12369] Add local holdouts support to Java SDK
1 parent 5752354 commit d055117

11 files changed

Lines changed: 721 additions & 81 deletions

File tree

core-api/src/main/java/com/optimizely/ab/bucketing/DecisionService.java

Lines changed: 68 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -325,9 +325,10 @@ public List<DecisionResponse<FeatureDecision>> getVariationsForFeatureList(@Non
325325
DecisionReasons reasons = DefaultDecisionReasons.newInstance();
326326
reasons.merge(upsReasons);
327327

328-
List<Holdout> holdouts = projectConfig.getHoldoutForFlag(featureFlag.getId());
329-
if (!holdouts.isEmpty()) {
330-
for (Holdout holdout : holdouts) {
328+
// Evaluate global holdouts at flag level (before any rules are iterated)
329+
List<Holdout> globalHoldouts = projectConfig.getGlobalHoldouts();
330+
if (!globalHoldouts.isEmpty()) {
331+
for (Holdout holdout : globalHoldouts) {
331332
DecisionResponse<Variation> holdoutDecision = getVariationForHoldout(holdout, user, projectConfig);
332333
reasons.merge(holdoutDecision.getReasons());
333334
if (holdoutDecision.getResult() != null) {
@@ -395,12 +396,44 @@ DecisionResponse<FeatureDecision> getVariationFromExperiment(@Nonnull ProjectCon
395396
@Nullable UserProfileTracker userProfileTracker,
396397
@Nonnull DecisionPath decisionPath) {
397398
DecisionReasons reasons = DefaultDecisionReasons.newInstance();
399+
// Cache flagKey once to avoid multiple getKey() calls (important for mock-based tests)
400+
String flagKey = featureFlag.getKey();
398401
if (!featureFlag.getExperimentIds().isEmpty()) {
399402
for (String experimentId : featureFlag.getExperimentIds()) {
400403
Experiment experiment = projectConfig.getExperimentIdMapping().get(experimentId);
401404

405+
// Step 1: Check forced decision for this experiment rule first (highest priority).
406+
// We must do this before the local holdout check so forced decisions win.
407+
if (experiment != null) {
408+
String ruleKey = experiment.getKey();
409+
OptimizelyDecisionContext fdContext = new OptimizelyDecisionContext(flagKey, ruleKey);
410+
DecisionResponse<Variation> fdResponse = validatedForcedDecision(fdContext, projectConfig, user);
411+
reasons.merge(fdResponse.getReasons());
412+
if (fdResponse.getResult() != null) {
413+
return new DecisionResponse<>(
414+
new FeatureDecision(experiment, fdResponse.getResult(), FeatureDecision.DecisionSource.FEATURE_TEST),
415+
reasons);
416+
}
417+
418+
// Step 2: Check local holdouts targeting this experiment rule.
419+
// Local holdouts run after forced decisions but before regular rule evaluation.
420+
List<Holdout> localHoldouts = projectConfig.getHoldoutsForRule(experiment.getId());
421+
for (Holdout holdout : localHoldouts) {
422+
DecisionResponse<Variation> holdoutDecision = getVariationForHoldout(holdout, user, projectConfig);
423+
reasons.merge(holdoutDecision.getReasons());
424+
if (holdoutDecision.getResult() != null) {
425+
return new DecisionResponse<>(
426+
new FeatureDecision(holdout, holdoutDecision.getResult(), FeatureDecision.DecisionSource.HOLDOUT),
427+
reasons);
428+
}
429+
}
430+
}
431+
432+
// Step 3: Regular rule evaluation (getVariationFromExperimentRule also checks
433+
// forced decisions internally but it will find no forced decision since we already
434+
// checked above; the duplicate check is harmless).
402435
DecisionResponse<Variation> decisionVariation =
403-
getVariationFromExperimentRule(projectConfig, featureFlag.getKey(), experiment, user, options, userProfileTracker, decisionPath);
436+
getVariationFromExperimentRule(projectConfig, flagKey, experiment, user, options, userProfileTracker, decisionPath);
404437
reasons.merge(decisionVariation.getReasons());
405438
Variation variation = decisionVariation.getResult();
406439
String cmabUuid = decisionVariation.getCmabUuid();
@@ -421,7 +454,7 @@ DecisionResponse<FeatureDecision> getVariationFromExperiment(@Nonnull ProjectCon
421454
}
422455
}
423456
} else {
424-
String message = reasons.addInfo("The feature flag \"%s\" is not used in any experiments.", featureFlag.getKey());
457+
String message = reasons.addInfo("The feature flag \"%s\" is not used in any experiments.", flagKey);
425458
logger.info(message);
426459
}
427460

@@ -468,7 +501,33 @@ DecisionResponse<FeatureDecision> getVariationForFeatureInRollout(@Nonnull Featu
468501

469502
int index = 0;
470503
while (index < rolloutRulesLength) {
504+
Experiment rolloutRule = rollout.getExperiments().get(index);
505+
506+
// Step 1: Check forced decision for this delivery rule (highest priority).
507+
String rolloutRuleKey = rolloutRule.getKey();
508+
OptimizelyDecisionContext rolloutFdContext = new OptimizelyDecisionContext(featureFlag.getKey(), rolloutRuleKey);
509+
DecisionResponse<Variation> rolloutFdResponse = validatedForcedDecision(rolloutFdContext, projectConfig, user);
510+
reasons.merge(rolloutFdResponse.getReasons());
511+
if (rolloutFdResponse.getResult() != null) {
512+
FeatureDecision featureDecision = new FeatureDecision(rolloutRule, rolloutFdResponse.getResult(), FeatureDecision.DecisionSource.ROLLOUT);
513+
return new DecisionResponse<>(featureDecision, reasons);
514+
}
471515

516+
// Step 2: Check local holdouts targeting this delivery rule.
517+
// Local holdouts run after forced decisions but before regular delivery rule evaluation.
518+
List<Holdout> rolloutLocalHoldouts = projectConfig.getHoldoutsForRule(rolloutRule.getId());
519+
for (Holdout holdout : rolloutLocalHoldouts) {
520+
DecisionResponse<Variation> holdoutDecision = getVariationForHoldout(holdout, user, projectConfig);
521+
reasons.merge(holdoutDecision.getReasons());
522+
if (holdoutDecision.getResult() != null) {
523+
return new DecisionResponse<>(
524+
new FeatureDecision(holdout, holdoutDecision.getResult(), FeatureDecision.DecisionSource.HOLDOUT),
525+
reasons);
526+
}
527+
}
528+
529+
// Step 3: Regular delivery rule evaluation (getVariationFromDeliveryRule also checks
530+
// forced decisions internally; the duplicate check is harmless).
472531
DecisionResponse<AbstractMap.SimpleEntry> decisionVariationResponse = getVariationFromDeliveryRule(
473532
projectConfig,
474533
featureFlag.getKey(),
@@ -836,7 +895,7 @@ private DecisionResponse<Variation> getVariationFromExperimentRule(@Nonnull Proj
836895
DecisionReasons reasons = DefaultDecisionReasons.newInstance();
837896

838897
String ruleKey = rule != null ? rule.getKey() : null;
839-
// Check Forced-Decision
898+
// Step 1: Check Forced-Decision
840899
OptimizelyDecisionContext optimizelyDecisionContext = new OptimizelyDecisionContext(flagKey, ruleKey);
841900
DecisionResponse<Variation> forcedDecisionResponse = validatedForcedDecision(optimizelyDecisionContext, projectConfig, user);
842901

@@ -846,7 +905,9 @@ private DecisionResponse<Variation> getVariationFromExperimentRule(@Nonnull Proj
846905
if (variation != null) {
847906
return new DecisionResponse(variation, reasons);
848907
}
849-
//regular decision
908+
909+
// Regular rule decision (local holdouts for experiment rules are checked by the caller
910+
// getVariationFromExperiment, where the FeatureDecision source can be set to HOLDOUT)
850911
DecisionResponse<Variation> decisionResponse = getVariation(rule, user, projectConfig, options, userProfileTracker, null, decisionPath);
851912
reasons.merge(decisionResponse.getReasons());
852913

core-api/src/main/java/com/optimizely/ab/config/DatafileProjectConfig.java

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -575,7 +575,17 @@ public List<Holdout> getHoldoutForFlag(@Nonnull String id) {
575575
return holdoutConfig.getHoldoutForFlag(id);
576576
}
577577

578-
@Override
578+
@Override
579+
public List<Holdout> getGlobalHoldouts() {
580+
return holdoutConfig.getGlobalHoldouts();
581+
}
582+
583+
@Override
584+
public List<Holdout> getHoldoutsForRule(@Nonnull String ruleId) {
585+
return holdoutConfig.getHoldoutsForRule(ruleId);
586+
}
587+
588+
@Override
579589
public Holdout getHoldout(@Nonnull String id) {
580590
return holdoutConfig.getHoldout(id);
581591
}

core-api/src/main/java/com/optimizely/ab/config/Holdout.java

Lines changed: 56 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/**
22
*
3-
* Copyright 2016-2019, 2021, Optimizely and contributors
3+
* Copyright 2016-2019, 2021, 2026, Optimizely and contributors
44
*
55
* Licensed under the Apache License, Version 2.0 (the "License");
66
* you may not use this file except in compliance with the License.
@@ -38,12 +38,20 @@ public class Holdout implements ExperimentCore {
3838
private final String id;
3939
private final String key;
4040
private final String status;
41-
41+
4242
private final List<String> audienceIds;
4343
private final Condition<AudienceIdCondition> audienceConditions;
4444
private final List<Variation> variations;
4545
private final List<TrafficAllocation> trafficAllocation;
4646

47+
/**
48+
* Optional list of rule IDs this holdout targets. When null, the holdout is global
49+
* (applies to all rules across all flags). When non-null (even empty), it is a local
50+
* holdout that only applies to the specified rule IDs.
51+
*/
52+
@Nullable
53+
private final List<String> includedRules;
54+
4755
private final Map<String, Variation> variationKeyToVariationMap;
4856
private final Map<String, Variation> variationIdToVariationMap;
4957
// Not necessary for HO
@@ -68,25 +76,45 @@ public String toString() {
6876

6977
@VisibleForTesting
7078
public Holdout(String id, String key) {
71-
this(id, key, "Running", Collections.emptyList(), null, Collections.emptyList(), Collections.emptyList());
72-
}
73-
74-
// Keep only this constructor and add @JsonCreator to it
79+
this(id, key, "Running", Collections.emptyList(), null, Collections.emptyList(), Collections.emptyList(), null);
80+
}
81+
82+
/**
83+
* Constructor without includedRules (backward-compatible — treated as global holdout).
84+
*/
85+
public Holdout(@Nonnull String id,
86+
@Nonnull String key,
87+
@Nonnull String status,
88+
@Nonnull List<String> audienceIds,
89+
@Nullable Condition audienceConditions,
90+
@Nonnull List<Variation> variations,
91+
@Nonnull List<TrafficAllocation> trafficAllocation) {
92+
this(id, key, status, audienceIds, audienceConditions, variations, trafficAllocation, null);
93+
}
94+
95+
/**
96+
* Full constructor including optional includedRules field (used by parsers).
97+
*
98+
* @param includedRules null = global holdout (applies to all rules); non-null list = local holdout
99+
* targeting only those rule IDs (empty list = local holdout with no matching rules)
100+
*/
75101
@JsonCreator
76102
public Holdout(@JsonProperty("id") @Nonnull String id,
77103
@JsonProperty("key") @Nonnull String key,
78104
@JsonProperty("status") @Nonnull String status,
79105
@JsonProperty("audienceIds") @Nonnull List<String> audienceIds,
80106
@JsonProperty("audienceConditions") @Nullable Condition audienceConditions,
81107
@JsonProperty("variations") @Nonnull List<Variation> variations,
82-
@JsonProperty("trafficAllocation") @Nonnull List<TrafficAllocation> trafficAllocation) {
108+
@JsonProperty("trafficAllocation") @Nonnull List<TrafficAllocation> trafficAllocation,
109+
@JsonProperty("includedRules") @Nullable List<String> includedRules) {
83110
this.id = id;
84111
this.key = key;
85112
this.status = status;
86113
this.audienceIds = audienceIds;
87114
this.audienceConditions = audienceConditions;
88115
this.variations = variations;
89116
this.trafficAllocation = trafficAllocation;
117+
this.includedRules = includedRules;
90118
this.variationKeyToVariationMap = ProjectConfigUtils.generateNameMapping(this.variations);
91119
this.variationIdToVariationMap = ProjectConfigUtils.generateIdMapping(this.variations);
92120
}
@@ -143,6 +171,26 @@ public boolean isRunning() {
143171
return status.equals(Holdout.HoldoutStatus.RUNNING.toString());
144172
}
145173

174+
/**
175+
* Returns the list of rule IDs this holdout targets, or null if this is a global holdout.
176+
*
177+
* @return null for global holdouts; a (possibly empty) list of rule IDs for local holdouts
178+
*/
179+
@Nullable
180+
public List<String> getIncludedRules() {
181+
return includedRules;
182+
}
183+
184+
/**
185+
* Returns true if this holdout is global (applies to all rules across all flags).
186+
* A holdout is global when includedRules is null.
187+
*
188+
* @return true if this is a global holdout, false if it is a local holdout
189+
*/
190+
public boolean isGlobal() {
191+
return includedRules == null;
192+
}
193+
146194
@Override
147195
public String toString() {
148196
return "Holdout {"
@@ -154,6 +202,7 @@ public String toString() {
154202
+ ", variations=" + variations
155203
+ ", variationKeyToVariationMap=" + variationKeyToVariationMap
156204
+ ", trafficAllocation=" + trafficAllocation
205+
+ ", includedRules=" + includedRules
157206
+ '}';
158207
}
159208
}

core-api/src/main/java/com/optimizely/ab/config/HoldoutConfig.java

Lines changed: 61 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/**
22
*
3-
* Copyright 2016-2019, 2021, Optimizely and contributors
3+
* Copyright 2016-2019, 2021, 2026, Optimizely and contributors
44
*
55
* Licensed under the Apache License, Version 2.0 (the "License");
66
* you may not use this file except in compliance with the License.
@@ -28,13 +28,19 @@
2828
import javax.annotation.Nullable;
2929

3030
/**
31-
* HoldoutConfig manages collections of Holdout objects.
32-
* All holdouts are global and apply to all flags.
31+
* HoldoutConfig manages collections of Holdout objects, distinguishing between global holdouts
32+
* (which apply to all rules) and local holdouts (which target specific rule IDs).
3333
*/
3434
public class HoldoutConfig {
3535
private List<Holdout> allHoldouts;
3636
private Map<String, Holdout> holdoutIdMap;
3737

38+
/** Global holdouts: holdouts where includedRules == null. Evaluated at flag level. */
39+
private List<Holdout> globalHoldouts;
40+
41+
/** Rule-level map: ruleId -> list of local holdouts targeting that rule. */
42+
private Map<String, List<Holdout>> ruleHoldoutsMap;
43+
3844
/**
3945
* Initializes a new HoldoutConfig with an empty list of holdouts.
4046
*/
@@ -50,28 +56,76 @@ public HoldoutConfig() {
5056
public HoldoutConfig(@Nonnull List<Holdout> allHoldouts) {
5157
this.allHoldouts = new ArrayList<>(allHoldouts);
5258
this.holdoutIdMap = new HashMap<>();
59+
this.globalHoldouts = new ArrayList<>();
60+
this.ruleHoldoutsMap = new HashMap<>();
5361
updateHoldoutMapping();
5462
}
5563

5664
/**
57-
* Updates internal mapping of holdout IDs to holdout objects.
65+
* Updates internal mappings:
66+
* - holdoutIdMap: id -> Holdout
67+
* - globalHoldouts: holdouts where includedRules == null
68+
* - ruleHoldoutsMap: ruleId -> list of holdouts that include that rule
5869
*/
5970
private void updateHoldoutMapping() {
6071
holdoutIdMap.clear();
72+
globalHoldouts.clear();
73+
ruleHoldoutsMap.clear();
74+
6175
for (Holdout holdout : allHoldouts) {
6276
holdoutIdMap.put(holdout.getId(), holdout);
77+
78+
if (holdout.isGlobal()) {
79+
// includedRules == null: global holdout — applies to all rules
80+
globalHoldouts.add(holdout);
81+
} else {
82+
// includedRules != null: local holdout — add to each targeted rule
83+
List<String> includedRules = holdout.getIncludedRules();
84+
for (String ruleId : includedRules) {
85+
if (!ruleHoldoutsMap.containsKey(ruleId)) {
86+
ruleHoldoutsMap.put(ruleId, new ArrayList<>());
87+
}
88+
ruleHoldoutsMap.get(ruleId).add(holdout);
89+
}
90+
}
6391
}
6492
}
6593

94+
/**
95+
* Returns all global holdouts (holdouts where includedRules == null).
96+
* These are evaluated at the flag level, before any rules are evaluated.
97+
*
98+
* @return An unmodifiable list of global holdouts
99+
*/
100+
public List<Holdout> getGlobalHoldouts() {
101+
return Collections.unmodifiableList(globalHoldouts);
102+
}
103+
104+
/**
105+
* Returns local holdouts targeting a specific rule ID.
106+
* These are evaluated per-rule, after the forced decision check and before regular rule evaluation.
107+
*
108+
* @param ruleId The rule identifier to look up
109+
* @return An unmodifiable list of local holdouts targeting that rule, or empty list if none
110+
*/
111+
@Nonnull
112+
public List<Holdout> getHoldoutsForRule(@Nonnull String ruleId) {
113+
List<Holdout> holdouts = ruleHoldoutsMap.get(ruleId);
114+
return holdouts != null ? Collections.unmodifiableList(holdouts) : Collections.emptyList();
115+
}
116+
66117
/**
67118
* Returns all holdouts for the given flag ID.
68-
* Since all holdouts are now global, this returns all holdouts.
119+
* For backward compatibility: returns all global holdouts (same behavior as before local holdouts).
69120
*
70121
* @param id The flag identifier
71-
* @return A list of all Holdout objects
122+
* @return A list of global Holdout objects
123+
* @deprecated Use {@link #getGlobalHoldouts()} for flag-level evaluation and
124+
* {@link #getHoldoutsForRule(String)} for per-rule evaluation.
72125
*/
126+
@Deprecated
73127
public List<Holdout> getHoldoutForFlag(@Nonnull String id) {
74-
return Collections.unmodifiableList(allHoldouts);
128+
return Collections.unmodifiableList(globalHoldouts);
75129
}
76130

77131
/**

core-api/src/main/java/com/optimizely/ab/config/ProjectConfig.java

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,21 @@ Experiment getExperimentForKey(@Nonnull String experimentKey,
7575

7676
List<Holdout> getHoldoutForFlag(@Nonnull String id);
7777

78+
/**
79+
* Returns all global holdouts (holdouts where includedRules == null).
80+
* Evaluated at flag level, before any rules are iterated.
81+
*/
82+
List<Holdout> getGlobalHoldouts();
83+
84+
/**
85+
* Returns local holdouts targeting a specific rule ID.
86+
* Evaluated per-rule, after forced decision check and before regular rule evaluation.
87+
*
88+
* @param ruleId The rule identifier to look up
89+
* @return List of local holdouts for that rule, or empty list if none
90+
*/
91+
List<Holdout> getHoldoutsForRule(@Nonnull String ruleId);
92+
7893
Holdout getHoldout(@Nonnull String id);
7994

8095
Set<String> getAllSegments();

0 commit comments

Comments
 (0)