Skip to content
Closed
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
Original file line number Diff line number Diff line change
Expand Up @@ -325,9 +325,10 @@ public List<DecisionResponse<FeatureDecision>> getVariationsForFeatureList(@Non
DecisionReasons reasons = DefaultDecisionReasons.newInstance();
reasons.merge(upsReasons);

List<Holdout> holdouts = projectConfig.getHoldoutForFlag(featureFlag.getId());
if (!holdouts.isEmpty()) {
for (Holdout holdout : holdouts) {
// Check global holdouts first (apply to all rules)
List<Holdout> globalHoldouts = projectConfig.getGlobalHoldouts();
if (!globalHoldouts.isEmpty()) {
for (Holdout holdout : globalHoldouts) {
DecisionResponse<Variation> holdoutDecision = getVariationForHoldout(holdout, user, projectConfig);
reasons.merge(holdoutDecision.getReasons());
if (holdoutDecision.getResult() != null) {
Expand Down Expand Up @@ -846,6 +847,20 @@ private DecisionResponse<Variation> getVariationFromExperimentRule(@Nonnull Proj
if (variation != null) {
return new DecisionResponse(variation, reasons);
}

// Check local holdouts targeting this specific rule
List<Holdout> localHoldouts = projectConfig.getHoldoutsForRule(rule.getId());
if (!localHoldouts.isEmpty()) {
for (Holdout holdout : localHoldouts) {
DecisionResponse<Variation> holdoutDecision = getVariationForHoldout(holdout, user, projectConfig);
reasons.merge(holdoutDecision.getReasons());
if (holdoutDecision.getResult() != null) {
// User is in local holdout - return holdout variation and skip this rule
return new DecisionResponse(holdoutDecision.getResult(), reasons);
}
}
}

//regular decision
DecisionResponse<Variation> decisionResponse = getVariation(rule, user, projectConfig, options, userProfileTracker, null, decisionPath);
reasons.merge(decisionResponse.getReasons());
Expand Down Expand Up @@ -896,6 +911,20 @@ DecisionResponse<AbstractMap.SimpleEntry> getVariationFromDeliveryRule(@Nonnull
return new DecisionResponse(variationToSkipToEveryoneElsePair, reasons);
}

// Check local holdouts targeting this delivery rule
List<Holdout> localHoldouts = projectConfig.getHoldoutsForRule(rule.getId());
if (!localHoldouts.isEmpty()) {
for (Holdout holdout : localHoldouts) {
DecisionResponse<Variation> holdoutDecision = getVariationForHoldout(holdout, user, projectConfig);
reasons.merge(holdoutDecision.getReasons());
if (holdoutDecision.getResult() != null) {
// User is in local holdout - return holdout variation and skip this delivery rule
variationToSkipToEveryoneElsePair = new AbstractMap.SimpleEntry<>(holdoutDecision.getResult(), skipToEveryoneElse);
return new DecisionResponse(variationToSkipToEveryoneElsePair, reasons);
}
}
}

// Handle a regular decision
String bucketingId = getBucketingId(user.getUserId(), user.getAttributes());
Boolean everyoneElse = (ruleIndex == rules.size() - 1);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -575,7 +575,17 @@ public List<Holdout> getHoldoutForFlag(@Nonnull String id) {
return holdoutConfig.getHoldoutForFlag(id);
}

@Override
@Override
public List<Holdout> getGlobalHoldouts() {
return holdoutConfig.getGlobalHoldouts();
}

@Override
public List<Holdout> getHoldoutsForRule(@Nonnull String ruleId) {
return holdoutConfig.getHoldoutsForRule(ruleId);
}

@Override
public Holdout getHoldout(@Nonnull String id) {
return holdoutConfig.getHoldout(id);
}
Expand Down
17 changes: 15 additions & 2 deletions core-api/src/main/java/com/optimizely/ab/config/Holdout.java
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ public class Holdout implements ExperimentCore {
private final Condition<AudienceIdCondition> audienceConditions;
private final List<Variation> variations;
private final List<TrafficAllocation> trafficAllocation;
private final List<String> includedRules;

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

@VisibleForTesting
public Holdout(String id, String key) {
this(id, key, "Running", Collections.emptyList(), null, Collections.emptyList(), Collections.emptyList());
this(id, key, "Running", Collections.emptyList(), null, Collections.emptyList(), Collections.emptyList(), null);
}

// Keep only this constructor and add @JsonCreator to it
Expand All @@ -79,14 +80,16 @@ public Holdout(@JsonProperty("id") @Nonnull String id,
@JsonProperty("audienceIds") @Nonnull List<String> audienceIds,
@JsonProperty("audienceConditions") @Nullable Condition audienceConditions,
@JsonProperty("variations") @Nonnull List<Variation> variations,
@JsonProperty("trafficAllocation") @Nonnull List<TrafficAllocation> trafficAllocation) {
@JsonProperty("trafficAllocation") @Nonnull List<TrafficAllocation> trafficAllocation,
@JsonProperty("includedRules") @Nullable List<String> includedRules) {
this.id = id;
this.key = key;
this.status = status;
this.audienceIds = audienceIds;
this.audienceConditions = audienceConditions;
this.variations = variations;
this.trafficAllocation = trafficAllocation;
this.includedRules = includedRules;
this.variationKeyToVariationMap = ProjectConfigUtils.generateNameMapping(this.variations);
this.variationIdToVariationMap = ProjectConfigUtils.generateIdMapping(this.variations);
}
Expand Down Expand Up @@ -131,6 +134,15 @@ public List<TrafficAllocation> getTrafficAllocation() {
return trafficAllocation;
}

@Nullable
public List<String> getIncludedRules() {
return includedRules;
}

public boolean isGlobal() {
return includedRules == null;
}

public String getGroupId() {
return "";
}
Expand All @@ -154,6 +166,7 @@ public String toString() {
+ ", variations=" + variations
+ ", variationKeyToVariationMap=" + variationKeyToVariationMap
+ ", trafficAllocation=" + trafficAllocation
+ ", includedRules=" + includedRules
+ '}';
}
}
56 changes: 51 additions & 5 deletions core-api/src/main/java/com/optimizely/ab/config/HoldoutConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,13 @@

/**
* HoldoutConfig manages collections of Holdout objects.
* All holdouts are global and apply to all flags.
* Supports both global holdouts (apply to all rules) and local holdouts (apply to specific rules).
*/
public class HoldoutConfig {
private List<Holdout> allHoldouts;
private Map<String, Holdout> holdoutIdMap;
private List<Holdout> globalHoldouts;
private Map<String, List<Holdout>> ruleHoldoutsMap;

/**
* Initializes a new HoldoutConfig with an empty list of holdouts.
Expand All @@ -50,28 +52,72 @@ public HoldoutConfig() {
public HoldoutConfig(@Nonnull List<Holdout> allHoldouts) {
this.allHoldouts = new ArrayList<>(allHoldouts);
this.holdoutIdMap = new HashMap<>();
this.globalHoldouts = new ArrayList<>();
this.ruleHoldoutsMap = new HashMap<>();
updateHoldoutMapping();
}

/**
* Updates internal mapping of holdout IDs to holdout objects.
* Updates internal mappings of holdout IDs and rule-level holdouts.
* Separates global holdouts (includedRules == null) from local holdouts (includedRules != null).
*/
private void updateHoldoutMapping() {
holdoutIdMap.clear();
globalHoldouts.clear();
ruleHoldoutsMap.clear();

for (Holdout holdout : allHoldouts) {
holdoutIdMap.put(holdout.getId(), holdout);

if (holdout.isGlobal()) {
// Global holdout: applies to all rules
globalHoldouts.add(holdout);
} else {
// Local holdout: applies to specific rules
List<String> includedRules = holdout.getIncludedRules();
if (includedRules != null) {
for (String ruleId : includedRules) {
ruleHoldoutsMap.computeIfAbsent(ruleId, k -> new ArrayList<>()).add(holdout);
}
}
}
}
}

/**
* Returns all global holdouts (those that apply to all rules).
*
* @return An unmodifiable list of global holdouts
*/
@Nonnull
public List<Holdout> getGlobalHoldouts() {
return Collections.unmodifiableList(globalHoldouts);
}

/**
* Returns local holdouts that target a specific rule.
*
* @param ruleId The rule identifier
* @return A list of holdouts targeting this rule, or an empty list if none
*/
@Nonnull
public List<Holdout> getHoldoutsForRule(@Nonnull String ruleId) {
List<Holdout> holdouts = ruleHoldoutsMap.get(ruleId);
return holdouts != null ? Collections.unmodifiableList(holdouts) : Collections.emptyList();
}

/**
* Returns all holdouts for the given flag ID.
* Since all holdouts are now global, this returns all holdouts.
* Since all holdouts are now global, this returns all global holdouts.
* This method is deprecated; use getGlobalHoldouts() instead.
*
* @param id The flag identifier
* @return A list of all Holdout objects
* @return A list of all global Holdout objects
* @deprecated Use {@link #getGlobalHoldouts()} instead
*/
@Deprecated
public List<Holdout> getHoldoutForFlag(@Nonnull String id) {
return Collections.unmodifiableList(allHoldouts);
return getGlobalHoldouts();
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,10 @@ Experiment getExperimentForKey(@Nonnull String experimentKey,

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

List<Holdout> getGlobalHoldouts();

List<Holdout> getHoldoutsForRule(@Nonnull String ruleId);

Holdout getHoldout(@Nonnull String id);

Set<String> getAllSegments();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,17 @@ static Holdout parseHoldout(JsonObject holdoutJson, JsonDeserializationContext c
List<TrafficAllocation> trafficAllocations =
parseTrafficAllocation(holdoutJson.getAsJsonArray("trafficAllocation"));

return new Holdout(id, key, status, audienceIds, conditions, variations, trafficAllocations);
// parse includedRules (optional field for local holdouts, null for global holdouts)
List<String> includedRules = null;
if (holdoutJson.has("includedRules") && !holdoutJson.get("includedRules").isJsonNull()) {
JsonArray includedRulesJson = holdoutJson.getAsJsonArray("includedRules");
includedRules = new ArrayList<>(includedRulesJson.size());
for (JsonElement ruleIdObj : includedRulesJson) {
includedRules.add(ruleIdObj.getAsString());
}
}

return new Holdout(id, key, status, audienceIds, conditions, variations, trafficAllocations, includedRules);
}

static FeatureFlag parseFeatureFlag(JsonObject featureFlagJson, JsonDeserializationContext context) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -218,8 +218,18 @@ private List<Holdout> parseHoldouts(JSONArray holdoutJson) {
List<TrafficAllocation> trafficAllocations =
parseTrafficAllocation(holdoutObject.getJSONArray("trafficAllocation"));

// parse includedRules (optional field for local holdouts, null for global holdouts)
List<String> includedRules = null;
if (holdoutObject.has("includedRules") && !holdoutObject.isNull("includedRules")) {
JSONArray includedRulesJson = holdoutObject.getJSONArray("includedRules");
includedRules = new ArrayList<String>(includedRulesJson.length());
for (int j = 0; j < includedRulesJson.length(); j++) {
includedRules.add(includedRulesJson.getString(j));
}
}

holdouts.add(new Holdout(id, key, status, audienceIds, conditions, variations,
trafficAllocations));
trafficAllocations, includedRules));
}

return holdouts;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -237,8 +237,18 @@ private List<Holdout> parseHoldouts(JSONArray holdoutJson) {
List<TrafficAllocation> trafficAllocations =
parseTrafficAllocation((JSONArray) hoObject.get("trafficAllocation"));

// parse includedRules (optional field for local holdouts, null for global holdouts)
List<String> includedRules = null;
if (hoObject.containsKey("includedRules") && hoObject.get("includedRules") != null) {
JSONArray includedRulesJson = (JSONArray) hoObject.get("includedRules");
includedRules = new ArrayList<String>(includedRulesJson.size());
for (Object ruleIdObj : includedRulesJson) {
includedRules.add((String) ruleIdObj);
}
}

holdouts.add(new Holdout(id, key, status, audienceIds, conditions, variations,
trafficAllocations));
trafficAllocations, includedRules));
}

return holdouts;
Expand Down
Loading
Loading