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 (before any rules are iterated)
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 @@ -395,12 +396,44 @@ DecisionResponse<FeatureDecision> getVariationFromExperiment(@Nonnull ProjectCon
@Nullable UserProfileTracker userProfileTracker,
@Nonnull DecisionPath decisionPath) {
DecisionReasons reasons = DefaultDecisionReasons.newInstance();
// Cache flagKey once to avoid multiple getKey() calls (important for mock-based tests)
String flagKey = featureFlag.getKey();
if (!featureFlag.getExperimentIds().isEmpty()) {
for (String experimentId : featureFlag.getExperimentIds()) {
Experiment experiment = projectConfig.getExperimentIdMapping().get(experimentId);

// Step 1: Check forced decision for this experiment rule first (highest priority).
// We must do this before the local holdout check so forced decisions win.
if (experiment != null) {
String ruleKey = experiment.getKey();
OptimizelyDecisionContext fdContext = new OptimizelyDecisionContext(flagKey, ruleKey);
DecisionResponse<Variation> fdResponse = validatedForcedDecision(fdContext, projectConfig, user);
reasons.merge(fdResponse.getReasons());
if (fdResponse.getResult() != null) {
return new DecisionResponse<>(
new FeatureDecision(experiment, fdResponse.getResult(), FeatureDecision.DecisionSource.FEATURE_TEST),
reasons);
}

// Step 2: Check local holdouts targeting this experiment rule.
// Local holdouts run after forced decisions but before regular rule evaluation.
List<Holdout> localHoldouts = projectConfig.getHoldoutsForRule(experiment.getId());
for (Holdout holdout : localHoldouts) {
DecisionResponse<Variation> holdoutDecision = getVariationForHoldout(holdout, user, projectConfig);
reasons.merge(holdoutDecision.getReasons());
if (holdoutDecision.getResult() != null) {
return new DecisionResponse<>(
new FeatureDecision(holdout, holdoutDecision.getResult(), FeatureDecision.DecisionSource.HOLDOUT),
reasons);
}
}
}

// Step 3: Regular rule evaluation (getVariationFromExperimentRule also checks
// forced decisions internally but it will find no forced decision since we already
// checked above; the duplicate check is harmless).
DecisionResponse<Variation> decisionVariation =
getVariationFromExperimentRule(projectConfig, featureFlag.getKey(), experiment, user, options, userProfileTracker, decisionPath);
getVariationFromExperimentRule(projectConfig, flagKey, experiment, user, options, userProfileTracker, decisionPath);
reasons.merge(decisionVariation.getReasons());
Variation variation = decisionVariation.getResult();
String cmabUuid = decisionVariation.getCmabUuid();
Expand All @@ -421,7 +454,7 @@ DecisionResponse<FeatureDecision> getVariationFromExperiment(@Nonnull ProjectCon
}
}
} else {
String message = reasons.addInfo("The feature flag \"%s\" is not used in any experiments.", featureFlag.getKey());
String message = reasons.addInfo("The feature flag \"%s\" is not used in any experiments.", flagKey);
logger.info(message);
}

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

int index = 0;
while (index < rolloutRulesLength) {
Experiment rolloutRule = rollout.getExperiments().get(index);

// Step 1: Check forced decision for this delivery rule (highest priority).
String rolloutRuleKey = rolloutRule.getKey();
OptimizelyDecisionContext rolloutFdContext = new OptimizelyDecisionContext(featureFlag.getKey(), rolloutRuleKey);
DecisionResponse<Variation> rolloutFdResponse = validatedForcedDecision(rolloutFdContext, projectConfig, user);
reasons.merge(rolloutFdResponse.getReasons());
if (rolloutFdResponse.getResult() != null) {
FeatureDecision featureDecision = new FeatureDecision(rolloutRule, rolloutFdResponse.getResult(), FeatureDecision.DecisionSource.ROLLOUT);
return new DecisionResponse<>(featureDecision, reasons);
}

// Step 2: Check local holdouts targeting this delivery rule.
// Local holdouts run after forced decisions but before regular delivery rule evaluation.
List<Holdout> rolloutLocalHoldouts = projectConfig.getHoldoutsForRule(rolloutRule.getId());
for (Holdout holdout : rolloutLocalHoldouts) {
DecisionResponse<Variation> holdoutDecision = getVariationForHoldout(holdout, user, projectConfig);
reasons.merge(holdoutDecision.getReasons());
if (holdoutDecision.getResult() != null) {
return new DecisionResponse<>(
new FeatureDecision(holdout, holdoutDecision.getResult(), FeatureDecision.DecisionSource.HOLDOUT),
reasons);
}
}

// Step 3: Regular delivery rule evaluation (getVariationFromDeliveryRule also checks
// forced decisions internally; the duplicate check is harmless).
DecisionResponse<AbstractMap.SimpleEntry> decisionVariationResponse = getVariationFromDeliveryRule(
projectConfig,
featureFlag.getKey(),
Expand Down Expand Up @@ -836,7 +895,7 @@ private DecisionResponse<Variation> getVariationFromExperimentRule(@Nonnull Proj
DecisionReasons reasons = DefaultDecisionReasons.newInstance();

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

Expand All @@ -846,7 +905,9 @@ private DecisionResponse<Variation> getVariationFromExperimentRule(@Nonnull Proj
if (variation != null) {
return new DecisionResponse(variation, reasons);
}
//regular decision

// Regular rule decision (local holdouts for experiment rules are checked by the caller
// getVariationFromExperiment, where the FeatureDecision source can be set to HOLDOUT)
DecisionResponse<Variation> decisionResponse = getVariation(rule, user, projectConfig, options, userProfileTracker, null, decisionPath);
reasons.merge(decisionResponse.getReasons());

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
63 changes: 56 additions & 7 deletions core-api/src/main/java/com/optimizely/ab/config/Holdout.java
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/**
*
* Copyright 2016-2019, 2021, Optimizely and contributors
* Copyright 2016-2019, 2021, 2026, Optimizely and contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
Expand Down Expand Up @@ -38,12 +38,20 @@ 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;

/**
* Optional list of rule IDs this holdout targets. When null, the holdout is global
* (applies to all rules across all flags). When non-null (even empty), it is a local
* holdout that only applies to the specified rule IDs.
*/
@Nullable
private final List<String> includedRules;

private final Map<String, Variation> variationKeyToVariationMap;
private final Map<String, Variation> variationIdToVariationMap;
// Not necessary for HO
Expand All @@ -68,25 +76,45 @@ public String toString() {

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

// Keep only this constructor and add @JsonCreator to it
this(id, key, "Running", Collections.emptyList(), null, Collections.emptyList(), Collections.emptyList(), null);
}

/**
* Constructor without includedRules (backward-compatible — treated as global holdout).
*/
public Holdout(@Nonnull String id,
@Nonnull String key,
@Nonnull String status,
@Nonnull List<String> audienceIds,
@Nullable Condition audienceConditions,
@Nonnull List<Variation> variations,
@Nonnull List<TrafficAllocation> trafficAllocation) {
this(id, key, status, audienceIds, audienceConditions, variations, trafficAllocation, null);
}

/**
* Full constructor including optional includedRules field (used by parsers).
*
* @param includedRules null = global holdout (applies to all rules); non-null list = local holdout
* targeting only those rule IDs (empty list = local holdout with no matching rules)
*/
@JsonCreator
public Holdout(@JsonProperty("id") @Nonnull String id,
@JsonProperty("key") @Nonnull String key,
@JsonProperty("status") @Nonnull String status,
@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 @@ -143,6 +171,26 @@ public boolean isRunning() {
return status.equals(Holdout.HoldoutStatus.RUNNING.toString());
}

/**
* Returns the list of rule IDs this holdout targets, or null if this is a global holdout.
*
* @return null for global holdouts; a (possibly empty) list of rule IDs for local holdouts
*/
@Nullable
public List<String> getIncludedRules() {
return includedRules;
}

/**
* Returns true if this holdout is global (applies to all rules across all flags).
* A holdout is global when includedRules is null.
*
* @return true if this is a global holdout, false if it is a local holdout
*/
public boolean isGlobal() {
return includedRules == null;
}

@Override
public String toString() {
return "Holdout {"
Expand All @@ -154,6 +202,7 @@ public String toString() {
+ ", variations=" + variations
+ ", variationKeyToVariationMap=" + variationKeyToVariationMap
+ ", trafficAllocation=" + trafficAllocation
+ ", includedRules=" + includedRules
+ '}';
}
}
68 changes: 61 additions & 7 deletions core-api/src/main/java/com/optimizely/ab/config/HoldoutConfig.java
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/**
*
* Copyright 2016-2019, 2021, Optimizely and contributors
* Copyright 2016-2019, 2021, 2026, Optimizely and contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
Expand Down Expand Up @@ -28,13 +28,19 @@
import javax.annotation.Nullable;

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

/** Global holdouts: holdouts where includedRules == null. Evaluated at flag level. */
private List<Holdout> globalHoldouts;

/** Rule-level map: ruleId -> list of local holdouts targeting that rule. */
private Map<String, List<Holdout>> ruleHoldoutsMap;

/**
* Initializes a new HoldoutConfig with an empty list of holdouts.
*/
Expand All @@ -50,28 +56,76 @@ 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:
* - holdoutIdMap: id -> Holdout
* - globalHoldouts: holdouts where includedRules == null
* - ruleHoldoutsMap: ruleId -> list of holdouts that include that rule
*/
private void updateHoldoutMapping() {
holdoutIdMap.clear();
globalHoldouts.clear();
ruleHoldoutsMap.clear();

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

if (holdout.isGlobal()) {
// includedRules == null: global holdout — applies to all rules
globalHoldouts.add(holdout);
} else {
// includedRules != null: local holdout — add to each targeted rule
List<String> includedRules = holdout.getIncludedRules();
for (String ruleId : includedRules) {
if (!ruleHoldoutsMap.containsKey(ruleId)) {
ruleHoldoutsMap.put(ruleId, new ArrayList<>());
}
ruleHoldoutsMap.get(ruleId).add(holdout);
}
}
}
}

/**
* Returns all global holdouts (holdouts where includedRules == null).
* These are evaluated at the flag level, before any rules are evaluated.
*
* @return An unmodifiable list of global holdouts
*/
public List<Holdout> getGlobalHoldouts() {
return Collections.unmodifiableList(globalHoldouts);
}

/**
* Returns local holdouts targeting a specific rule ID.
* These are evaluated per-rule, after the forced decision check and before regular rule evaluation.
*
* @param ruleId The rule identifier to look up
* @return An unmodifiable list of local holdouts targeting that rule, or 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.
* For backward compatibility: returns all global holdouts (same behavior as before local holdouts).
*
* @param id The flag identifier
* @return A list of all Holdout objects
* @return A list of global Holdout objects
* @deprecated Use {@link #getGlobalHoldouts()} for flag-level evaluation and
* {@link #getHoldoutsForRule(String)} for per-rule evaluation.
*/
@Deprecated
public List<Holdout> getHoldoutForFlag(@Nonnull String id) {
return Collections.unmodifiableList(allHoldouts);
return Collections.unmodifiableList(globalHoldouts);
}

/**
Expand Down
15 changes: 15 additions & 0 deletions core-api/src/main/java/com/optimizely/ab/config/ProjectConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,21 @@ Experiment getExperimentForKey(@Nonnull String experimentKey,

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

/**
* Returns all global holdouts (holdouts where includedRules == null).
* Evaluated at flag level, before any rules are iterated.
*/
List<Holdout> getGlobalHoldouts();

/**
* Returns local holdouts targeting a specific rule ID.
* Evaluated per-rule, after forced decision check and before regular rule evaluation.
*
* @param ruleId The rule identifier to look up
* @return List of local holdouts for that rule, or empty list if none
*/
List<Holdout> getHoldoutsForRule(@Nonnull String ruleId);

Holdout getHoldout(@Nonnull String id);

Set<String> getAllSegments();
Expand Down
Loading
Loading