Skip to content
Open
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) {
// Evaluate global holdouts at flag level
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,22 @@ private DecisionResponse<Variation> getVariationFromExperimentRule(@Nonnull Proj
if (variation != null) {
return new DecisionResponse(variation, reasons);
}

// Check local holdouts targeting this rule
if (rule != null) {
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 holdout - return holdout variation immediately, 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 +913,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 holdout - return holdout variation, 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 @@ -571,11 +571,16 @@ public List<Holdout> getHoldouts() {
}

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

@Override
@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
21 changes: 9 additions & 12 deletions core-api/src/main/java/com/optimizely/ab/config/Holdout.java
Original file line number Diff line number Diff line change
Expand Up @@ -38,13 +38,12 @@ public class Holdout implements ExperimentCore {
private final String id;
private final String key;
private final String status;

private final List<String> audienceIds;
private final Condition<AudienceIdCondition> audienceConditions;
private final List<Variation> variations;
private final List<TrafficAllocation> trafficAllocation;
private final List<String> includedFlags;
private final List<String> excludedFlags;
private final List<String> includedRules;

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

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

// Keep only this constructor and add @JsonCreator to it
Expand All @@ -82,17 +81,15 @@ public Holdout(@JsonProperty("id") @Nonnull String id,
@JsonProperty("audienceConditions") @Nullable Condition audienceConditions,
@JsonProperty("variations") @Nonnull List<Variation> variations,
@JsonProperty("trafficAllocation") @Nonnull List<TrafficAllocation> trafficAllocation,
@JsonProperty("includedFlags") @Nullable List<String> includedFlags,
@JsonProperty("excludedFlags") @Nullable List<String> excludedFlags) {
@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.includedFlags = includedFlags == null ? Collections.emptyList() : Collections.unmodifiableList(includedFlags);
this.excludedFlags = excludedFlags == null ? Collections.emptyList() : Collections.unmodifiableList(excludedFlags);
this.includedRules = includedRules;
this.variationKeyToVariationMap = ProjectConfigUtils.generateNameMapping(this.variations);
this.variationIdToVariationMap = ProjectConfigUtils.generateIdMapping(this.variations);
}
Expand Down Expand Up @@ -141,12 +138,12 @@ public String getGroupId() {
return "";
}

public List<String> getIncludedFlags() {
return includedFlags;
public List<String> getIncludedRules() {
return includedRules;
}

public List<String> getExcludedFlags() {
return excludedFlags;
public boolean isGlobal() {
return includedRules == null;
}

public boolean isActive() {
Expand Down
94 changes: 30 additions & 64 deletions core-api/src/main/java/com/optimizely/ab/config/HoldoutConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,9 @@
*/
public class HoldoutConfig {
private List<Holdout> allHoldouts;
private List<Holdout> global;
private List<Holdout> globalHoldouts;
private Map<String, Holdout> holdoutIdMap;
private Map<String, List<Holdout>> flagHoldoutsMap;
private Map<String, List<Holdout>> includedHoldouts;
private Map<String, Set<Holdout>> excludedHoldouts;
private Map<String, List<Holdout>> ruleHoldoutsMap;

/**
* Initializes a new HoldoutConfig with an empty list of holdouts.
Expand All @@ -55,91 +53,59 @@ public HoldoutConfig() {
*/
public HoldoutConfig(@Nonnull List<Holdout> allHoldouts) {
this.allHoldouts = new ArrayList<>(allHoldouts);
this.global = new ArrayList<>();
this.globalHoldouts = new ArrayList<>();
this.holdoutIdMap = new HashMap<>();
this.flagHoldoutsMap = new ConcurrentHashMap<>();
this.includedHoldouts = new HashMap<>();
this.excludedHoldouts = new HashMap<>();
this.ruleHoldoutsMap = new HashMap<>();
updateHoldoutMapping();
}

/**
* Updates internal mappings of holdouts including the id map, global list,
* and per-flag inclusion/exclusion maps.
* and per-rule holdout maps.
*/
private void updateHoldoutMapping() {
holdoutIdMap.clear();
for (Holdout holdout : allHoldouts) {
holdoutIdMap.put(holdout.getId(), holdout);
}

flagHoldoutsMap.clear();
global.clear();
includedHoldouts.clear();
excludedHoldouts.clear();
globalHoldouts.clear();
ruleHoldoutsMap.clear();

for (Holdout holdout : allHoldouts) {
boolean hasIncludedFlags = !holdout.getIncludedFlags().isEmpty();
boolean hasExcludedFlags = !holdout.getExcludedFlags().isEmpty();

if (!hasIncludedFlags && !hasExcludedFlags) {
// Global holdout (applies to all flags)
global.add(holdout);
} else if (hasIncludedFlags) {
// Holdout only applies to specific included flags
for (String flagId : holdout.getIncludedFlags()) {
includedHoldouts.computeIfAbsent(flagId, k -> new ArrayList<>()).add(holdout);
}
if (holdout.isGlobal()) {
// Global holdout (includedRules == null, applies to all rules)
globalHoldouts.add(holdout);
} else {
// Global holdout with specific exclusions
global.add(holdout);

for (String flagId : holdout.getExcludedFlags()) {
excludedHoldouts.computeIfAbsent(flagId, k -> new HashSet<>()).add(holdout);
// Local holdout (includedRules != null, 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 the applicable holdouts for the given flag ID by combining global holdouts
* (excluding any specified) and included holdouts, in that order.
* Caches the result for future calls.
* Returns global holdouts that apply to all rules.
*
* @param id The flag identifier
* @return A list of Holdout objects relevant to the given flag
* @return A list of global Holdout objects
*/
public List<Holdout> getHoldoutForFlag(@Nonnull String id) {
if (allHoldouts.isEmpty()) {
return Collections.emptyList();
}

// Check cache and return persistent holdouts
if (flagHoldoutsMap.containsKey(id)) {
return flagHoldoutsMap.get(id);
}

// Prioritize global holdouts first
List<Holdout> activeHoldouts = new ArrayList<>();
Set<Holdout> excluded = excludedHoldouts.getOrDefault(id, Collections.emptySet());

if (!excluded.isEmpty()) {
for (Holdout holdout : global) {
if (!excluded.contains(holdout)) {
activeHoldouts.add(holdout);
}
}
} else {
activeHoldouts.addAll(global);
}

// Add included holdouts
activeHoldouts.addAll(includedHoldouts.getOrDefault(id, Collections.emptyList()));

// Cache the result
flagHoldoutsMap.put(id, activeHoldouts);
public List<Holdout> getGlobalHoldouts() {
return Collections.unmodifiableList(globalHoldouts);
}

return activeHoldouts;
/**
* Returns local holdouts that target a specific rule.
*
* @param ruleId The rule identifier
* @return A list of Holdout objects targeting the specified rule
*/
public List<Holdout> getHoldoutsForRule(@Nonnull String ruleId) {
List<Holdout> holdouts = ruleHoldoutsMap.get(ruleId);
return holdouts == null ? Collections.emptyList() : Collections.unmodifiableList(holdouts);
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,9 @@ Experiment getExperimentForKey(@Nonnull String experimentKey,

List<Holdout > getHoldouts();

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

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

Holdout getHoldout(@Nonnull String id);

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

List<String> includedFlags = new ArrayList<>();
if (holdoutJson.has("includedFlags")) {
JsonArray includedIdsJson = holdoutJson.getAsJsonArray("includedFlags");
for (JsonElement hoIdObj : includedIdsJson) {
includedFlags.add(hoIdObj.getAsString());
List<String> includedRules = null;
if (holdoutJson.has("includedRules")) {
JsonArray includedRulesJson = holdoutJson.getAsJsonArray("includedRules");
includedRules = new ArrayList<>();
for (JsonElement ruleIdObj : includedRulesJson) {
includedRules.add(ruleIdObj.getAsString());
}
}

List<String> excludedFlags = new ArrayList<>();
if (holdoutJson.has("excludedFlags")) {
JsonArray excludedIdsJson = holdoutJson.getAsJsonArray("excludedFlags");
for (JsonElement hoIdObj : excludedIdsJson) {
excludedFlags.add(hoIdObj.getAsString());
}
}

return new Holdout(id, key, status, audienceIds, conditions, variations, trafficAllocations, includedFlags, excludedFlags);
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 @@ -242,34 +242,19 @@ private List<Holdout> parseHoldouts(JSONArray holdoutJson) {
List<TrafficAllocation> trafficAllocations =
parseTrafficAllocation(holdoutObject.getJSONArray("trafficAllocation"));

List<String> includedFlags;
if (holdoutObject.has("includedFlags")) {
JSONArray includedIdsJson = holdoutObject.getJSONArray("includedFlags");
includedFlags = new ArrayList<>(includedIdsJson.length());

for (int j = 0; j < includedIdsJson.length(); j++) {
Object idObj = includedIdsJson.get(j);
includedFlags.add((String) idObj);
List<String> includedRules = null;
if (holdoutObject.has("includedRules")) {
JSONArray includedRulesJson = holdoutObject.getJSONArray("includedRules");
includedRules = new ArrayList<>(includedRulesJson.length());

for (int j = 0; j < includedRulesJson.length(); j++) {
Object idObj = includedRulesJson.get(j);
includedRules.add((String) idObj);
}
} else {
includedFlags = Collections.emptyList();
}

List<String> excludedFlags;
if (holdoutObject.has("excludedFlags")) {
JSONArray excludedIdsJson = holdoutObject.getJSONArray("excludedFlags");
excludedFlags = new ArrayList<>(excludedIdsJson.length());

for (int j = 0; j < excludedIdsJson.length(); j++) {
Object idObj = excludedIdsJson.get(j);
excludedFlags.add((String) idObj);
}
} else {
excludedFlags = Collections.emptyList();
}

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

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

List<String> includedFlags;
if (hoObject.containsKey("includedFlags")) {
includedFlags = new ArrayList<String>((JSONArray) hoObject.get("includedFlags"));
} else {
includedFlags = Collections.emptyList();
}

List<String> excludedFlags;
if (hoObject.containsKey("excludedFlags")) {
excludedFlags = new ArrayList<String>((JSONArray) hoObject.get("excludedFlags"));
} else {
excludedFlags = Collections.emptyList();
List<String> includedRules = null;
if (hoObject.containsKey("includedRules")) {
includedRules = new ArrayList<String>((JSONArray) hoObject.get("includedRules"));
}

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

return holdouts;
Expand Down
Loading
Loading