Skip to content

Commit f03a39b

Browse files
Mat001claude
andcommitted
[AI-FSSDK] [FSSDK-12369] Add local holdouts support with includedRules field
- Add includedRules field to Holdout (null = global, non-null = local) - Add isGlobal() method: returns true when includedRules is null - Update HoldoutConfig to separate global vs local holdouts with ruleHoldoutsMap - Add getGlobalHoldouts() and getHoldoutsForRule() to HoldoutConfig, ProjectConfig, DatafileProjectConfig - Update DecisionService to check global holdouts at flag level and local holdouts per rule - Update all 4 JSON parsers (Gson, Jackson, org.json, json-simple) to parse optional includedRules - Expand HoldoutConfigTest with comprehensive local holdout coverage Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 5752354 commit f03a39b

9 files changed

Lines changed: 366 additions & 24 deletions

File tree

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

Lines changed: 32 additions & 3 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+
// Check global holdouts first (apply to all rules)
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) {
@@ -846,6 +847,20 @@ private DecisionResponse<Variation> getVariationFromExperimentRule(@Nonnull Proj
846847
if (variation != null) {
847848
return new DecisionResponse(variation, reasons);
848849
}
850+
851+
// Check local holdouts targeting this specific rule
852+
List<Holdout> localHoldouts = projectConfig.getHoldoutsForRule(rule.getId());
853+
if (!localHoldouts.isEmpty()) {
854+
for (Holdout holdout : localHoldouts) {
855+
DecisionResponse<Variation> holdoutDecision = getVariationForHoldout(holdout, user, projectConfig);
856+
reasons.merge(holdoutDecision.getReasons());
857+
if (holdoutDecision.getResult() != null) {
858+
// User is in local holdout - return holdout variation and skip this rule
859+
return new DecisionResponse(holdoutDecision.getResult(), reasons);
860+
}
861+
}
862+
}
863+
849864
//regular decision
850865
DecisionResponse<Variation> decisionResponse = getVariation(rule, user, projectConfig, options, userProfileTracker, null, decisionPath);
851866
reasons.merge(decisionResponse.getReasons());
@@ -896,6 +911,20 @@ DecisionResponse<AbstractMap.SimpleEntry> getVariationFromDeliveryRule(@Nonnull
896911
return new DecisionResponse(variationToSkipToEveryoneElsePair, reasons);
897912
}
898913

914+
// Check local holdouts targeting this delivery rule
915+
List<Holdout> localHoldouts = projectConfig.getHoldoutsForRule(rule.getId());
916+
if (!localHoldouts.isEmpty()) {
917+
for (Holdout holdout : localHoldouts) {
918+
DecisionResponse<Variation> holdoutDecision = getVariationForHoldout(holdout, user, projectConfig);
919+
reasons.merge(holdoutDecision.getReasons());
920+
if (holdoutDecision.getResult() != null) {
921+
// User is in local holdout - return holdout variation and skip this delivery rule
922+
variationToSkipToEveryoneElsePair = new AbstractMap.SimpleEntry<>(holdoutDecision.getResult(), skipToEveryoneElse);
923+
return new DecisionResponse(variationToSkipToEveryoneElsePair, reasons);
924+
}
925+
}
926+
}
927+
899928
// Handle a regular decision
900929
String bucketingId = getBucketingId(user.getUserId(), user.getAttributes());
901930
Boolean everyoneElse = (ruleIndex == rules.size() - 1);

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: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ public class Holdout implements ExperimentCore {
4343
private final Condition<AudienceIdCondition> audienceConditions;
4444
private final List<Variation> variations;
4545
private final List<TrafficAllocation> trafficAllocation;
46+
private final List<String> includedRules;
4647

4748
private final Map<String, Variation> variationKeyToVariationMap;
4849
private final Map<String, Variation> variationIdToVariationMap;
@@ -68,7 +69,7 @@ public String toString() {
6869

6970
@VisibleForTesting
7071
public Holdout(String id, String key) {
71-
this(id, key, "Running", Collections.emptyList(), null, Collections.emptyList(), Collections.emptyList());
72+
this(id, key, "Running", Collections.emptyList(), null, Collections.emptyList(), Collections.emptyList(), null);
7273
}
7374

7475
// Keep only this constructor and add @JsonCreator to it
@@ -79,14 +80,16 @@ public Holdout(@JsonProperty("id") @Nonnull String id,
7980
@JsonProperty("audienceIds") @Nonnull List<String> audienceIds,
8081
@JsonProperty("audienceConditions") @Nullable Condition audienceConditions,
8182
@JsonProperty("variations") @Nonnull List<Variation> variations,
82-
@JsonProperty("trafficAllocation") @Nonnull List<TrafficAllocation> trafficAllocation) {
83+
@JsonProperty("trafficAllocation") @Nonnull List<TrafficAllocation> trafficAllocation,
84+
@JsonProperty("includedRules") @Nullable List<String> includedRules) {
8385
this.id = id;
8486
this.key = key;
8587
this.status = status;
8688
this.audienceIds = audienceIds;
8789
this.audienceConditions = audienceConditions;
8890
this.variations = variations;
8991
this.trafficAllocation = trafficAllocation;
92+
this.includedRules = includedRules;
9093
this.variationKeyToVariationMap = ProjectConfigUtils.generateNameMapping(this.variations);
9194
this.variationIdToVariationMap = ProjectConfigUtils.generateIdMapping(this.variations);
9295
}
@@ -131,6 +134,15 @@ public List<TrafficAllocation> getTrafficAllocation() {
131134
return trafficAllocation;
132135
}
133136

137+
@Nullable
138+
public List<String> getIncludedRules() {
139+
return includedRules;
140+
}
141+
142+
public boolean isGlobal() {
143+
return includedRules == null;
144+
}
145+
134146
public String getGroupId() {
135147
return "";
136148
}
@@ -154,6 +166,7 @@ public String toString() {
154166
+ ", variations=" + variations
155167
+ ", variationKeyToVariationMap=" + variationKeyToVariationMap
156168
+ ", trafficAllocation=" + trafficAllocation
169+
+ ", includedRules=" + includedRules
157170
+ '}';
158171
}
159172
}

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

Lines changed: 51 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,11 +29,13 @@
2929

3030
/**
3131
* HoldoutConfig manages collections of Holdout objects.
32-
* All holdouts are global and apply to all flags.
32+
* Supports both global holdouts (apply to all rules) and local holdouts (apply to specific rules).
3333
*/
3434
public class HoldoutConfig {
3535
private List<Holdout> allHoldouts;
3636
private Map<String, Holdout> holdoutIdMap;
37+
private List<Holdout> globalHoldouts;
38+
private Map<String, List<Holdout>> ruleHoldoutsMap;
3739

3840
/**
3941
* Initializes a new HoldoutConfig with an empty list of holdouts.
@@ -50,28 +52,72 @@ public HoldoutConfig() {
5052
public HoldoutConfig(@Nonnull List<Holdout> allHoldouts) {
5153
this.allHoldouts = new ArrayList<>(allHoldouts);
5254
this.holdoutIdMap = new HashMap<>();
55+
this.globalHoldouts = new ArrayList<>();
56+
this.ruleHoldoutsMap = new HashMap<>();
5357
updateHoldoutMapping();
5458
}
5559

5660
/**
57-
* Updates internal mapping of holdout IDs to holdout objects.
61+
* Updates internal mappings of holdout IDs and rule-level holdouts.
62+
* Separates global holdouts (includedRules == null) from local holdouts (includedRules != null).
5863
*/
5964
private void updateHoldoutMapping() {
6065
holdoutIdMap.clear();
66+
globalHoldouts.clear();
67+
ruleHoldoutsMap.clear();
68+
6169
for (Holdout holdout : allHoldouts) {
6270
holdoutIdMap.put(holdout.getId(), holdout);
71+
72+
if (holdout.isGlobal()) {
73+
// Global holdout: applies to all rules
74+
globalHoldouts.add(holdout);
75+
} else {
76+
// Local holdout: applies to specific rules
77+
List<String> includedRules = holdout.getIncludedRules();
78+
if (includedRules != null) {
79+
for (String ruleId : includedRules) {
80+
ruleHoldoutsMap.computeIfAbsent(ruleId, k -> new ArrayList<>()).add(holdout);
81+
}
82+
}
83+
}
6384
}
6485
}
6586

87+
/**
88+
* Returns all global holdouts (those that apply to all rules).
89+
*
90+
* @return An unmodifiable list of global holdouts
91+
*/
92+
@Nonnull
93+
public List<Holdout> getGlobalHoldouts() {
94+
return Collections.unmodifiableList(globalHoldouts);
95+
}
96+
97+
/**
98+
* Returns local holdouts that target a specific rule.
99+
*
100+
* @param ruleId The rule identifier
101+
* @return A list of holdouts targeting this rule, or an empty list if none
102+
*/
103+
@Nonnull
104+
public List<Holdout> getHoldoutsForRule(@Nonnull String ruleId) {
105+
List<Holdout> holdouts = ruleHoldoutsMap.get(ruleId);
106+
return holdouts != null ? Collections.unmodifiableList(holdouts) : Collections.emptyList();
107+
}
108+
66109
/**
67110
* Returns all holdouts for the given flag ID.
68-
* Since all holdouts are now global, this returns all holdouts.
111+
* Since all holdouts are now global, this returns all global holdouts.
112+
* This method is deprecated; use getGlobalHoldouts() instead.
69113
*
70114
* @param id The flag identifier
71-
* @return A list of all Holdout objects
115+
* @return A list of all global Holdout objects
116+
* @deprecated Use {@link #getGlobalHoldouts()} instead
72117
*/
118+
@Deprecated
73119
public List<Holdout> getHoldoutForFlag(@Nonnull String id) {
74-
return Collections.unmodifiableList(allHoldouts);
120+
return getGlobalHoldouts();
75121
}
76122

77123
/**

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

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

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

78+
List<Holdout> getGlobalHoldouts();
79+
80+
List<Holdout> getHoldoutsForRule(@Nonnull String ruleId);
81+
7882
Holdout getHoldout(@Nonnull String id);
7983

8084
Set<String> getAllSegments();

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

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -202,7 +202,17 @@ static Holdout parseHoldout(JsonObject holdoutJson, JsonDeserializationContext c
202202
List<TrafficAllocation> trafficAllocations =
203203
parseTrafficAllocation(holdoutJson.getAsJsonArray("trafficAllocation"));
204204

205-
return new Holdout(id, key, status, audienceIds, conditions, variations, trafficAllocations);
205+
// parse includedRules (optional field for local holdouts, null for global holdouts)
206+
List<String> includedRules = null;
207+
if (holdoutJson.has("includedRules") && !holdoutJson.get("includedRules").isJsonNull()) {
208+
JsonArray includedRulesJson = holdoutJson.getAsJsonArray("includedRules");
209+
includedRules = new ArrayList<>(includedRulesJson.size());
210+
for (JsonElement ruleIdObj : includedRulesJson) {
211+
includedRules.add(ruleIdObj.getAsString());
212+
}
213+
}
214+
215+
return new Holdout(id, key, status, audienceIds, conditions, variations, trafficAllocations, includedRules);
206216
}
207217

208218
static FeatureFlag parseFeatureFlag(JsonObject featureFlagJson, JsonDeserializationContext context) {

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

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -218,8 +218,18 @@ private List<Holdout> parseHoldouts(JSONArray holdoutJson) {
218218
List<TrafficAllocation> trafficAllocations =
219219
parseTrafficAllocation(holdoutObject.getJSONArray("trafficAllocation"));
220220

221+
// parse includedRules (optional field for local holdouts, null for global holdouts)
222+
List<String> includedRules = null;
223+
if (holdoutObject.has("includedRules") && !holdoutObject.isNull("includedRules")) {
224+
JSONArray includedRulesJson = holdoutObject.getJSONArray("includedRules");
225+
includedRules = new ArrayList<String>(includedRulesJson.length());
226+
for (int j = 0; j < includedRulesJson.length(); j++) {
227+
includedRules.add(includedRulesJson.getString(j));
228+
}
229+
}
230+
221231
holdouts.add(new Holdout(id, key, status, audienceIds, conditions, variations,
222-
trafficAllocations));
232+
trafficAllocations, includedRules));
223233
}
224234

225235
return holdouts;

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

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -237,8 +237,18 @@ private List<Holdout> parseHoldouts(JSONArray holdoutJson) {
237237
List<TrafficAllocation> trafficAllocations =
238238
parseTrafficAllocation((JSONArray) hoObject.get("trafficAllocation"));
239239

240+
// parse includedRules (optional field for local holdouts, null for global holdouts)
241+
List<String> includedRules = null;
242+
if (hoObject.containsKey("includedRules") && hoObject.get("includedRules") != null) {
243+
JSONArray includedRulesJson = (JSONArray) hoObject.get("includedRules");
244+
includedRules = new ArrayList<String>(includedRulesJson.size());
245+
for (Object ruleIdObj : includedRulesJson) {
246+
includedRules.add((String) ruleIdObj);
247+
}
248+
}
249+
240250
holdouts.add(new Holdout(id, key, status, audienceIds, conditions, variations,
241-
trafficAllocations));
251+
trafficAllocations, includedRules));
242252
}
243253

244254
return holdouts;

0 commit comments

Comments
 (0)