Skip to content

Commit c506793

Browse files
[AI-FSSDK] [FSSDK-12735] Add holdout exclusion logic for Targeted Delivery rules (#637)
1 parent c389de2 commit c506793

12 files changed

Lines changed: 636 additions & 44 deletions

File tree

‎core-api/src/main/java/com/optimizely/ab/Optimizely.java‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1356,6 +1356,19 @@ private OptimizelyDecision createOptimizelyDecision(
13561356
cmabUuid);
13571357
}
13581358

1359+
if (flagDecision.holdoutDecision != null && !allOptions.contains(OptimizelyDecideOption.DISABLE_DECISION_EVENT)) {
1360+
decisionEventDispatched = sendImpression(
1361+
projectConfig,
1362+
flagDecision.holdoutDecision.experiment,
1363+
userId,
1364+
copiedAttributes,
1365+
flagDecision.holdoutDecision.variation,
1366+
flagKey,
1367+
flagDecision.holdoutDecision.decisionSource != null ? flagDecision.holdoutDecision.decisionSource.toString() : FeatureDecision.DecisionSource.HOLDOUT.toString(),
1368+
flagDecision.holdoutDecision.variation != null && flagDecision.holdoutDecision.variation.getFeatureEnabled(),
1369+
null) || decisionEventDispatched;
1370+
}
1371+
13591372
DecisionNotification decisionNotification = DecisionNotification.newFlagDecisionNotificationBuilder()
13601373
.withUserId(userId)
13611374
.withAttributes(copiedAttributes)

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

Lines changed: 41 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/****************************************************************************
2-
* Copyright 2017-2022, 2024, Optimizely, Inc. and contributors *
2+
* Copyright 2017-2022, 2024, 2026, Optimizely, Inc. and contributors *
33
* *
44
* Licensed under the Apache License, Version 2.0 (the "License"); *
55
* you may not use this file except in compliance with the License. *
@@ -328,43 +328,66 @@ public List<DecisionResponse<FeatureDecision>> getVariationsForFeatureList(@Non
328328

329329
// Evaluate global holdouts at flag level (before any rules are iterated)
330330
List<Holdout> globalHoldouts = projectConfig.getGlobalHoldouts();
331+
FeatureDecision globalHoldoutDecision = null;
332+
boolean excludeTargetedDeliveries = false;
331333
if (!globalHoldouts.isEmpty()) {
332334
for (Holdout holdout : globalHoldouts) {
333335
DecisionResponse<Variation> holdoutDecision = getVariationForHoldout(holdout, user, projectConfig);
334336
reasons.merge(holdoutDecision.getReasons());
335337
if (holdoutDecision.getResult() != null) {
336-
decisions.add(new DecisionResponse<>(new FeatureDecision(holdout, holdoutDecision.getResult(), FeatureDecision.DecisionSource.HOLDOUT), reasons));
337-
continue flagLoop;
338+
globalHoldoutDecision = new FeatureDecision(holdout, holdoutDecision.getResult(), FeatureDecision.DecisionSource.HOLDOUT);
339+
excludeTargetedDeliveries = holdout.isExcludeTargetedDeliveries();
340+
break;
338341
}
339342
}
340343
}
341344

342-
DecisionResponse<FeatureDecision> decisionVariationResponse = getVariationFromExperiment(projectConfig, featureFlag, user, options, userProfileTracker, decisionPath);
343-
reasons.merge(decisionVariationResponse.getReasons());
345+
if (globalHoldoutDecision != null && !excludeTargetedDeliveries) {
346+
decisions.add(new DecisionResponse<>(globalHoldoutDecision, reasons));
347+
continue flagLoop;
348+
}
349+
350+
if (globalHoldoutDecision != null && excludeTargetedDeliveries) {
351+
reasons.addInfo("Holdout \"%s\" has excludeTargetedDeliveries enabled, continuing to rollout evaluation.", globalHoldoutDecision.experiment.getKey());
352+
}
344353

345-
FeatureDecision decision = decisionVariationResponse.getResult();
346-
boolean error = decisionVariationResponse.isError();
354+
if (globalHoldoutDecision == null) {
355+
DecisionResponse<FeatureDecision> decisionVariationResponse = getVariationFromExperiment(projectConfig, featureFlag, user, options, userProfileTracker, decisionPath);
356+
reasons.merge(decisionVariationResponse.getReasons());
347357

348-
if (decision != null) {
349-
decisions.add(new DecisionResponse(decision, reasons, error, decision.cmabUuid));
350-
continue;
358+
FeatureDecision decision = decisionVariationResponse.getResult();
359+
boolean error = decisionVariationResponse.isError();
360+
361+
if (decision != null) {
362+
decisions.add(new DecisionResponse(decision, reasons, error, decision.cmabUuid));
363+
continue flagLoop;
364+
}
351365
}
352366

353367
DecisionResponse<FeatureDecision> decisionFeatureResponse = getVariationForFeatureInRollout(featureFlag, user, projectConfig);
354368
reasons.merge(decisionFeatureResponse.getReasons());
355-
decision = decisionFeatureResponse.getResult();
369+
FeatureDecision decision = decisionFeatureResponse.getResult();
356370

357-
String message;
358-
if (decision.variation == null) {
359-
message = reasons.addInfo("The user \"%s\" was not bucketed into a rollout for feature flag \"%s\".",
371+
if (decision != null && decision.variation != null) {
372+
if (globalHoldoutDecision != null) {
373+
decision.setHoldoutDecision(globalHoldoutDecision);
374+
}
375+
String message = reasons.addInfo("The user \"%s\" was bucketed into a rollout for feature flag \"%s\".",
360376
user.getUserId(), featureFlag.getKey());
377+
logger.info(message);
378+
decisions.add(new DecisionResponse(decision, reasons));
361379
} else {
362-
message = reasons.addInfo("The user \"%s\" was bucketed into a rollout for feature flag \"%s\".",
380+
if (globalHoldoutDecision != null) {
381+
if (decision == null) {
382+
decision = new FeatureDecision(null, null, null);
383+
}
384+
decision.setHoldoutDecision(globalHoldoutDecision);
385+
}
386+
String message = reasons.addInfo("The user \"%s\" was not bucketed into a rollout for feature flag \"%s\".",
363387
user.getUserId(), featureFlag.getKey());
388+
logger.info(message);
389+
decisions.add(new DecisionResponse(decision, reasons));
364390
}
365-
logger.info(message);
366-
367-
decisions.add(new DecisionResponse(decision, reasons));
368391
}
369392

370393
if (userProfileService != null && !ignoreUPS) {

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,13 @@ public class FeatureDecision {
4545
@Nullable
4646
public String cmabUuid;
4747

48+
@Nullable
49+
public FeatureDecision holdoutDecision;
50+
51+
public void setHoldoutDecision(@Nullable FeatureDecision holdoutDecision) {
52+
this.holdoutDecision = holdoutDecision;
53+
}
54+
4855
public enum DecisionSource {
4956
FEATURE_TEST("feature-test"),
5057
ROLLOUT("rollout"),

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

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,8 @@ public class Holdout implements ExperimentCore {
5151
@Nullable
5252
private final List<String> includedRules;
5353

54+
private final boolean excludeTargetedDeliveries;
55+
5456
private final Map<String, Variation> variationKeyToVariationMap;
5557
private final Map<String, Variation> variationIdToVariationMap;
5658
// Not necessary for HO
@@ -75,7 +77,7 @@ public String toString() {
7577

7678
@VisibleForTesting
7779
public Holdout(String id, String key) {
78-
this(id, key, "Running", Collections.emptyList(), null, Collections.emptyList(), Collections.emptyList(), null);
80+
this(id, key, "Running", Collections.emptyList(), null, Collections.emptyList(), Collections.emptyList(), null, false);
7981
}
8082

8183
/**
@@ -88,7 +90,7 @@ public Holdout(@Nonnull String id,
8890
@Nullable Condition audienceConditions,
8991
@Nonnull List<Variation> variations,
9092
@Nonnull List<TrafficAllocation> trafficAllocation) {
91-
this(id, key, status, audienceIds, audienceConditions, variations, trafficAllocation, null);
93+
this(id, key, status, audienceIds, audienceConditions, variations, trafficAllocation, null, false);
9294
}
9395

9496
/**
@@ -105,7 +107,8 @@ public Holdout(@JsonProperty("id") @Nonnull String id,
105107
@JsonProperty("audienceConditions") @Nullable Condition audienceConditions,
106108
@JsonProperty("variations") @Nonnull List<Variation> variations,
107109
@JsonProperty("trafficAllocation") @Nonnull List<TrafficAllocation> trafficAllocation,
108-
@JsonProperty("includedRules") @Nullable List<String> includedRules) {
110+
@JsonProperty("includedRules") @Nullable List<String> includedRules,
111+
@JsonProperty("exclude_targeted_deliveries") @Nullable Boolean excludeTargetedDeliveries) {
109112
this.id = id;
110113
this.key = key;
111114
this.status = status;
@@ -114,6 +117,7 @@ public Holdout(@JsonProperty("id") @Nonnull String id,
114117
this.variations = variations;
115118
this.trafficAllocation = trafficAllocation;
116119
this.includedRules = includedRules;
120+
this.excludeTargetedDeliveries = excludeTargetedDeliveries != null ? excludeTargetedDeliveries : false;
117121
this.variationKeyToVariationMap = ProjectConfigUtils.generateNameMapping(this.variations);
118122
this.variationIdToVariationMap = ProjectConfigUtils.generateIdMapping(this.variations);
119123
}
@@ -188,6 +192,10 @@ public List<String> getIncludedRules() {
188192
*
189193
* @return true if this is a global holdout, false if it is a local holdout
190194
*/
195+
public boolean isExcludeTargetedDeliveries() {
196+
return excludeTargetedDeliveries;
197+
}
198+
191199
public boolean isGlobal() {
192200
return includedRules == null;
193201
}
@@ -204,6 +212,7 @@ public String toString() {
204212
+ ", variationKeyToVariationMap=" + variationKeyToVariationMap
205213
+ ", trafficAllocation=" + trafficAllocation
206214
+ ", includedRules=" + includedRules
215+
+ ", excludeTargetedDeliveries=" + excludeTargetedDeliveries
207216
+ '}';
208217
}
209218
}

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -193,7 +193,8 @@ private static Holdout stripIncludedRules(Holdout holdout) {
193193
holdout.getAudienceConditions(),
194194
holdout.getVariations(),
195195
holdout.getTrafficAllocation(),
196-
null
196+
null,
197+
holdout.isExcludeTargetedDeliveries()
197198
);
198199
}
199200

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

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/**
22
*
3-
* Copyright 2016-2017, 2019, Optimizely and contributors
3+
* Copyright 2016-2017, 2019, 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.
@@ -212,7 +212,12 @@ static Holdout parseHoldout(JsonObject holdoutJson, JsonDeserializationContext c
212212
}
213213
}
214214

215-
return new Holdout(id, key, status, audienceIds, conditions, variations, trafficAllocations, includedRules);
215+
boolean excludeTargetedDeliveries = false;
216+
if (holdoutJson.has("exclude_targeted_deliveries") && !holdoutJson.get("exclude_targeted_deliveries").isJsonNull()) {
217+
excludeTargetedDeliveries = holdoutJson.get("exclude_targeted_deliveries").getAsBoolean();
218+
}
219+
220+
return new Holdout(id, key, status, audienceIds, conditions, variations, trafficAllocations, includedRules, excludeTargetedDeliveries);
216221
}
217222

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

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

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/**
22
*
3-
* Copyright 2016-2022, Optimizely and contributors
3+
* Copyright 2016-2022, 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.
@@ -238,8 +238,13 @@ private List<Holdout> parseHoldouts(JSONArray holdoutJson) {
238238
}
239239
}
240240

241+
boolean excludeTargetedDeliveries = false;
242+
if (holdoutObject.has("exclude_targeted_deliveries") && !holdoutObject.isNull("exclude_targeted_deliveries")) {
243+
excludeTargetedDeliveries = holdoutObject.getBoolean("exclude_targeted_deliveries");
244+
}
245+
241246
holdouts.add(new Holdout(id, key, status, audienceIds, conditions, variations,
242-
trafficAllocations, includedRules));
247+
trafficAllocations, includedRules, excludeTargetedDeliveries));
243248
}
244249

245250
return holdouts;

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

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/**
22
*
3-
* Copyright 2016-2022, Optimizely and contributors
3+
* Copyright 2016-2022, 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.
@@ -257,8 +257,13 @@ private List<Holdout> parseHoldouts(JSONArray holdoutJson) {
257257
}
258258
}
259259

260+
boolean excludeTargetedDeliveries = false;
261+
if (hoObject.containsKey("exclude_targeted_deliveries") && hoObject.get("exclude_targeted_deliveries") != null) {
262+
excludeTargetedDeliveries = (Boolean) hoObject.get("exclude_targeted_deliveries");
263+
}
264+
260265
holdouts.add(new Holdout(id, key, status, audienceIds, conditions, variations,
261-
trafficAllocations, includedRules));
266+
trafficAllocations, includedRules, excludeTargetedDeliveries));
262267
}
263268

264269
return holdouts;

0 commit comments

Comments
 (0)