From 68c959ffacd65268f80a4ce7d2bd75063737a57b Mon Sep 17 00:00:00 2001 From: FarhanAnjum-opti Date: Thu, 12 Mar 2026 23:07:07 +0600 Subject: [PATCH] [AI-FSSDK] [FSSDK-12337] Add Feature Rollout support --- .../ab/config/DatafileProjectConfig.java | 108 +++++++++ .../com/optimizely/ab/config/Experiment.java | 42 +++- .../java/com/optimizely/ab/config/Group.java | 3 +- .../ab/config/parser/GsonHelpers.java | 10 +- .../ab/config/parser/JsonConfigParser.java | 4 +- .../config/parser/JsonSimpleConfigParser.java | 13 +- .../ab/config/FeatureRolloutConfigTest.java | 155 +++++++++++++ .../config/feature-rollout-config.json | 213 ++++++++++++++++++ 8 files changed, 537 insertions(+), 11 deletions(-) create mode 100644 core-api/src/test/java/com/optimizely/ab/config/FeatureRolloutConfigTest.java create mode 100644 core-api/src/test/resources/config/feature-rollout-config.json diff --git a/core-api/src/main/java/com/optimizely/ab/config/DatafileProjectConfig.java b/core-api/src/main/java/com/optimizely/ab/config/DatafileProjectConfig.java index e8dea8e90..7bb765818 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/DatafileProjectConfig.java +++ b/core-api/src/main/java/com/optimizely/ab/config/DatafileProjectConfig.java @@ -194,6 +194,10 @@ public DatafileProjectConfig(String accountId, List allExperiments = new ArrayList(); allExperiments.addAll(experiments); allExperiments.addAll(aggregateGroupExperiments(groups)); + + // Inject "everyone else" variation into feature_rollout experiments + allExperiments = injectFeatureRolloutVariations(allExperiments, this.featureFlags, this.rollouts); + this.experiments = Collections.unmodifiableList(allExperiments); if (holdouts == null) { @@ -357,6 +361,110 @@ public Experiment getExperimentForVariationId(String variationId) { return this.variationIdToExperimentMapping.get(variationId); } + /** + * Injects the "everyone else" variation from the flag's rollout into any experiment + * with type "feature_rollout". This enables Feature Rollout experiments to fall back + * to the everyone else variation when users are outside the rollout percentage. + */ + private List injectFeatureRolloutVariations( + List allExperiments, + List featureFlags, + List rollouts + ) { + if (featureFlags == null || featureFlags.isEmpty()) { + return allExperiments; + } + + // Build rollout ID to Rollout mapping + Map rolloutMap = new HashMap<>(); + if (rollouts != null) { + for (Rollout rollout : rollouts) { + rolloutMap.put(rollout.getId(), rollout); + } + } + + // Build experiment ID to index mapping for quick lookup + Map experimentIndexMap = new HashMap<>(); + for (int i = 0; i < allExperiments.size(); i++) { + experimentIndexMap.put(allExperiments.get(i).getId(), i); + } + + List result = new ArrayList<>(allExperiments); + + for (FeatureFlag flag : featureFlags) { + Variation everyoneElseVariation = getEveryoneElseVariation(flag, rolloutMap); + if (everyoneElseVariation == null) { + continue; + } + + for (String experimentId : flag.getExperimentIds()) { + Integer index = experimentIndexMap.get(experimentId); + if (index == null) { + continue; + } + Experiment experiment = result.get(index); + if (!"feature_rollout".equals(experiment.getType())) { + continue; + } + + // Create new experiment with injected variation and traffic allocation + List newVariations = new ArrayList<>(experiment.getVariations()); + newVariations.add(everyoneElseVariation); + + List newTrafficAllocation = new ArrayList<>(experiment.getTrafficAllocation()); + newTrafficAllocation.add(new TrafficAllocation(everyoneElseVariation.getId(), 10000)); + + Experiment updatedExperiment = new Experiment( + experiment.getId(), + experiment.getKey(), + experiment.getStatus(), + experiment.getLayerId(), + experiment.getAudienceIds(), + experiment.getAudienceConditions(), + newVariations, + experiment.getUserIdToVariationKeyMap(), + newTrafficAllocation, + experiment.getGroupId(), + experiment.getCmab(), + experiment.getType() + ); + + result.set(index, updatedExperiment); + } + } + + return result; + } + + /** + * Gets the "everyone else" variation from the flag's rollout. + * The everyone else rule is the last experiment in the rollout, + * and the variation is the first variation of that rule. + * + * @return the everyone else variation, or null if it cannot be resolved + */ + @Nullable + private Variation getEveryoneElseVariation(FeatureFlag flag, Map rolloutMap) { + String rolloutId = flag.getRolloutId(); + if (rolloutId == null || rolloutId.isEmpty()) { + return null; + } + Rollout rollout = rolloutMap.get(rolloutId); + if (rollout == null) { + return null; + } + List rolloutExperiments = rollout.getExperiments(); + if (rolloutExperiments == null || rolloutExperiments.isEmpty()) { + return null; + } + Experiment everyoneElseRule = rolloutExperiments.get(rolloutExperiments.size() - 1); + List variations = everyoneElseRule.getVariations(); + if (variations == null || variations.isEmpty()) { + return null; + } + return variations.get(0); + } + private List aggregateGroupExperiments(List groups) { List groupExperiments = new ArrayList(); for (Group group : groups) { diff --git a/core-api/src/main/java/com/optimizely/ab/config/Experiment.java b/core-api/src/main/java/com/optimizely/ab/config/Experiment.java index 7d687e9e9..c8189eaef 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/Experiment.java +++ b/core-api/src/main/java/com/optimizely/ab/config/Experiment.java @@ -41,6 +41,7 @@ public class Experiment implements ExperimentCore { private final String status; private final String layerId; private final String groupId; + private final String type; private final Cmab cmab; private final List audienceIds; @@ -72,7 +73,7 @@ public String toString() { @VisibleForTesting public Experiment(String id, String key, String layerId) { - this(id, key, null, layerId, Collections.emptyList(), null, Collections.emptyList(), Collections.emptyMap(), Collections.emptyList(), "", null); + this(id, key, null, layerId, Collections.emptyList(), null, Collections.emptyList(), Collections.emptyMap(), Collections.emptyList(), "", null, null); } @VisibleForTesting @@ -81,7 +82,7 @@ public Experiment(String id, String key, String status, String layerId, List variations, Map userIdToVariationKeyMap, List trafficAllocation, String groupId) { this(id, key, status, layerId, audienceIds, audienceConditions, variations, - userIdToVariationKeyMap, trafficAllocation, groupId, null); // Default cmab=null + userIdToVariationKeyMap, trafficAllocation, groupId, null, null); // Default cmab=null, type=null } @VisibleForTesting @@ -90,7 +91,27 @@ public Experiment(String id, String key, String status, String layerId, List variations, Map userIdToVariationKeyMap, List trafficAllocation) { this(id, key, status, layerId, audienceIds, audienceConditions, variations, - userIdToVariationKeyMap, trafficAllocation, "", null); // Default groupId="" and cmab=null + userIdToVariationKeyMap, trafficAllocation, "", null, null); // Default groupId="", cmab=null, type=null + } + + @VisibleForTesting + public Experiment(String id, String key, String status, String layerId, + List audienceIds, Condition audienceConditions, + List variations, Map userIdToVariationKeyMap, + List trafficAllocation, + Cmab cmab) { + this(id, key, status, layerId, audienceIds, audienceConditions, variations, + userIdToVariationKeyMap, trafficAllocation, "", cmab, null); // Default groupId="" and type=null + } + + @VisibleForTesting + public Experiment(String id, String key, String status, String layerId, + List audienceIds, Condition audienceConditions, + List variations, Map userIdToVariationKeyMap, + List trafficAllocation, String groupId, + Cmab cmab) { + this(id, key, status, layerId, audienceIds, audienceConditions, variations, + userIdToVariationKeyMap, trafficAllocation, groupId, cmab, null); // Default type=null } @JsonCreator @@ -103,8 +124,9 @@ public Experiment(@JsonProperty("id") String id, @JsonProperty("variations") List variations, @JsonProperty("forcedVariations") Map userIdToVariationKeyMap, @JsonProperty("trafficAllocation") List trafficAllocation, - @JsonProperty("cmab") Cmab cmab) { - this(id, key, status, layerId, audienceIds, audienceConditions, variations, userIdToVariationKeyMap, trafficAllocation, "", cmab); + @JsonProperty("cmab") Cmab cmab, + @JsonProperty("type") String type) { + this(id, key, status, layerId, audienceIds, audienceConditions, variations, userIdToVariationKeyMap, trafficAllocation, "", cmab, type); } public Experiment(@Nonnull String id, @@ -117,7 +139,8 @@ public Experiment(@Nonnull String id, @Nonnull Map userIdToVariationKeyMap, @Nonnull List trafficAllocation, @Nonnull String groupId, - @Nullable Cmab cmab) { + @Nullable Cmab cmab, + @Nullable String type) { this.id = id; this.key = key; this.status = status == null ? ExperimentStatus.NOT_STARTED.toString() : status; @@ -131,6 +154,7 @@ public Experiment(@Nonnull String id, this.variationKeyToVariationMap = ProjectConfigUtils.generateNameMapping(variations); this.variationIdToVariationMap = ProjectConfigUtils.generateIdMapping(variations); this.cmab = cmab; + this.type = type; } public String getId() { @@ -181,6 +205,11 @@ public String getGroupId() { return groupId; } + @Nullable + public String getType() { + return type; + } + public Cmab getCmab() { return cmab; } @@ -211,6 +240,7 @@ public String toString() { ", variationKeyToVariationMap=" + variationKeyToVariationMap + ", userIdToVariationKeyMap=" + userIdToVariationKeyMap + ", trafficAllocation=" + trafficAllocation + + ", type='" + type + '\'' + ", cmab=" + cmab + '}'; } diff --git a/core-api/src/main/java/com/optimizely/ab/config/Group.java b/core-api/src/main/java/com/optimizely/ab/config/Group.java index d0d9ff364..1e8cefbd7 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/Group.java +++ b/core-api/src/main/java/com/optimizely/ab/config/Group.java @@ -63,7 +63,8 @@ public Group(@JsonProperty("id") String id, experiment.getUserIdToVariationKeyMap(), experiment.getTrafficAllocation(), id, - experiment.getCmab() + experiment.getCmab(), + experiment.getType() ); } this.experiments.add(experiment); diff --git a/core-api/src/main/java/com/optimizely/ab/config/parser/GsonHelpers.java b/core-api/src/main/java/com/optimizely/ab/config/parser/GsonHelpers.java index 624f9f159..cfebbd9c8 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/parser/GsonHelpers.java +++ b/core-api/src/main/java/com/optimizely/ab/config/parser/GsonHelpers.java @@ -168,8 +168,16 @@ static Experiment parseExperiment(JsonObject experimentJson, String groupId, Jso } } + String type = null; + if (experimentJson.has("type")) { + JsonElement typeElement = experimentJson.get("type"); + if (!typeElement.isJsonNull()) { + type = typeElement.getAsString(); + } + } + return new Experiment(id, key, status, layerId, audienceIds, conditions, variations, userIdToVariationKeyMap, - trafficAllocations, groupId, cmab); + trafficAllocations, groupId, cmab, type); } static Experiment parseExperiment(JsonObject experimentJson, JsonDeserializationContext context) { diff --git a/core-api/src/main/java/com/optimizely/ab/config/parser/JsonConfigParser.java b/core-api/src/main/java/com/optimizely/ab/config/parser/JsonConfigParser.java index 10ca9685f..8d2c005b5 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/parser/JsonConfigParser.java +++ b/core-api/src/main/java/com/optimizely/ab/config/parser/JsonConfigParser.java @@ -179,8 +179,10 @@ private List parseExperiments(JSONArray experimentJson, String group cmab = parseCmab(cmabObject); } + String type = experimentObject.optString("type", null); + experiments.add(new Experiment(id, key, status, layerId, audienceIds, conditions, variations, userIdToVariationKeyMap, - trafficAllocations, groupId, cmab)); + trafficAllocations, groupId, cmab, type)); } return experiments; diff --git a/core-api/src/main/java/com/optimizely/ab/config/parser/JsonSimpleConfigParser.java b/core-api/src/main/java/com/optimizely/ab/config/parser/JsonSimpleConfigParser.java index 56215acc3..2accb9813 100644 --- a/core-api/src/main/java/com/optimizely/ab/config/parser/JsonSimpleConfigParser.java +++ b/core-api/src/main/java/com/optimizely/ab/config/parser/JsonSimpleConfigParser.java @@ -189,8 +189,17 @@ private List parseExperiments(JSONArray experimentJson, String group } } - experiments.add(new Experiment(id, key, status, layerId, audienceIds, conditions, variations, - userIdToVariationKeyMap, trafficAllocations, groupId, cmab)); + // Parse type field + String type = null; + if (experimentObject.containsKey("type")) { + Object typeObj = experimentObject.get("type"); + if (typeObj != null) { + type = (String) typeObj; + } + } + + experiments.add(new Experiment(id, key, status, layerId, audienceIds, conditions, variations, + userIdToVariationKeyMap, trafficAllocations, groupId, cmab, type)); } return experiments; diff --git a/core-api/src/test/java/com/optimizely/ab/config/FeatureRolloutConfigTest.java b/core-api/src/test/java/com/optimizely/ab/config/FeatureRolloutConfigTest.java new file mode 100644 index 000000000..d070b07cf --- /dev/null +++ b/core-api/src/test/java/com/optimizely/ab/config/FeatureRolloutConfigTest.java @@ -0,0 +1,155 @@ +/** + * + * Copyright 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. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.optimizely.ab.config; + +import com.optimizely.ab.config.parser.ConfigParseException; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; + +import static org.junit.Assert.*; + +import org.junit.Before; +import org.junit.Test; + +/** + * Tests for Feature Rollout support in {@link DatafileProjectConfig}. + */ +public class FeatureRolloutConfigTest { + + private ProjectConfig projectConfig; + + @Before + public void setUp() throws ConfigParseException, IOException { + InputStream is = getClass().getClassLoader().getResourceAsStream("config/feature-rollout-config.json"); + assertNotNull("Test fixture not found", is); + byte[] bytes = is.readAllBytes(); + String datafile = new String(bytes, StandardCharsets.UTF_8); + projectConfig = new DatafileProjectConfig.Builder().withDatafile(datafile).build(); + } + + /** + * Test 1: Backward compatibility - experiments without type field have type=null. + */ + @Test + public void experimentWithoutTypeFieldHasNullType() { + Experiment experiment = projectConfig.getExperimentKeyMapping().get("no_type_experiment"); + assertNotNull("Experiment should exist", experiment); + assertNull("Type should be null for experiments without type field", experiment.getType()); + } + + /** + * Test 2: Core injection - feature_rollout experiments get everyone else variation + * and trafficAllocation (endOfRange=10000) injected. + */ + @Test + public void featureRolloutExperimentGetsEveryoneElseVariationInjected() { + Experiment experiment = projectConfig.getExperimentKeyMapping().get("feature_rollout_experiment"); + assertNotNull("Experiment should exist", experiment); + assertEquals("feature_rollout", experiment.getType()); + + // Should have 2 variations: original + everyone else + assertEquals("Should have 2 variations after injection", 2, experiment.getVariations().size()); + + // Check the injected variation + Variation injectedVariation = experiment.getVariations().get(1); + assertEquals("everyone_else_var", injectedVariation.getId()); + assertEquals("everyone_else_variation", injectedVariation.getKey()); + + // Check the injected traffic allocation + List trafficAllocations = experiment.getTrafficAllocation(); + assertEquals("Should have 2 traffic allocations after injection", 2, trafficAllocations.size()); + TrafficAllocation injectedAllocation = trafficAllocations.get(1); + assertEquals("everyone_else_var", injectedAllocation.getEntityId()); + assertEquals(10000, injectedAllocation.getEndOfRange()); + } + + /** + * Test 3: Variation maps updated - all variation lookup maps contain the injected variation. + */ + @Test + public void variationMapsContainInjectedVariation() { + Experiment experiment = projectConfig.getExperimentKeyMapping().get("feature_rollout_experiment"); + assertNotNull("Experiment should exist", experiment); + + // Check variationKeyToVariationMap + Map keyMap = experiment.getVariationKeyToVariationMap(); + assertTrue("Key map should contain injected variation", + keyMap.containsKey("everyone_else_variation")); + + // Check variationIdToVariationMap + Map idMap = experiment.getVariationIdToVariationMap(); + assertTrue("ID map should contain injected variation", + idMap.containsKey("everyone_else_var")); + } + + /** + * Test 4: Non-rollout unchanged - A/B experiments are not modified by injection logic. + */ + @Test + public void abTestExperimentNotModified() { + Experiment experiment = projectConfig.getExperimentKeyMapping().get("ab_test_experiment"); + assertNotNull("Experiment should exist", experiment); + assertEquals("a/b", experiment.getType()); + + // Should still have exactly 2 original variations + assertEquals("A/B test should keep original 2 variations", 2, experiment.getVariations().size()); + assertEquals("control", experiment.getVariations().get(0).getKey()); + assertEquals("treatment", experiment.getVariations().get(1).getKey()); + + // Should still have exactly 2 original traffic allocations + assertEquals("A/B test should keep original 2 traffic allocations", + 2, experiment.getTrafficAllocation().size()); + } + + /** + * Test 5: No rollout edge case - feature_rollout experiment with empty rolloutId + * does not crash (silent skip). + */ + @Test + public void featureRolloutWithEmptyRolloutIdDoesNotCrash() { + Experiment experiment = projectConfig.getExperimentKeyMapping().get("rollout_no_rollout_id_experiment"); + assertNotNull("Experiment should exist", experiment); + assertEquals("feature_rollout", experiment.getType()); + + // Should keep only original variation since rollout cannot be resolved + assertEquals("Should keep only original variation", 1, experiment.getVariations().size()); + assertEquals("rollout_no_rollout_variation", experiment.getVariations().get(0).getKey()); + } + + /** + * Test 6: Type field parsed - experiments with type field in the datafile + * have the value correctly preserved after config parsing. + */ + @Test + public void typeFieldCorrectlyParsed() { + Experiment rolloutExp = projectConfig.getExperimentKeyMapping().get("feature_rollout_experiment"); + assertNotNull(rolloutExp); + assertEquals("feature_rollout", rolloutExp.getType()); + + Experiment abExp = projectConfig.getExperimentKeyMapping().get("ab_test_experiment"); + assertNotNull(abExp); + assertEquals("a/b", abExp.getType()); + + Experiment noTypeExp = projectConfig.getExperimentKeyMapping().get("no_type_experiment"); + assertNotNull(noTypeExp); + assertNull(noTypeExp.getType()); + } +} diff --git a/core-api/src/test/resources/config/feature-rollout-config.json b/core-api/src/test/resources/config/feature-rollout-config.json new file mode 100644 index 000000000..9c5c22330 --- /dev/null +++ b/core-api/src/test/resources/config/feature-rollout-config.json @@ -0,0 +1,213 @@ +{ + "accountId": "12345", + "anonymizeIP": false, + "sendFlagDecisions": true, + "botFiltering": false, + "projectId": "67890", + "revision": "1", + "sdkKey": "FeatureRolloutTest", + "environmentKey": "production", + "version": "4", + "audiences": [], + "typedAudiences": [], + "attributes": [], + "events": [], + "groups": [], + "integrations": [], + "experiments": [ + { + "id": "exp_rollout_1", + "key": "feature_rollout_experiment", + "status": "Running", + "layerId": "layer_1", + "audienceIds": [], + "forcedVariations": {}, + "type": "feature_rollout", + "variations": [ + { + "id": "var_rollout_1", + "key": "rollout_variation", + "featureEnabled": true + } + ], + "trafficAllocation": [ + { + "entityId": "var_rollout_1", + "endOfRange": 5000 + } + ] + }, + { + "id": "exp_ab_1", + "key": "ab_test_experiment", + "status": "Running", + "layerId": "layer_2", + "audienceIds": [], + "forcedVariations": {}, + "type": "a/b", + "variations": [ + { + "id": "var_ab_1", + "key": "control", + "featureEnabled": false + }, + { + "id": "var_ab_2", + "key": "treatment", + "featureEnabled": true + } + ], + "trafficAllocation": [ + { + "entityId": "var_ab_1", + "endOfRange": 5000 + }, + { + "entityId": "var_ab_2", + "endOfRange": 10000 + } + ] + }, + { + "id": "exp_no_type", + "key": "no_type_experiment", + "status": "Running", + "layerId": "layer_3", + "audienceIds": [], + "forcedVariations": {}, + "variations": [ + { + "id": "var_notype_1", + "key": "variation_1", + "featureEnabled": true + } + ], + "trafficAllocation": [ + { + "entityId": "var_notype_1", + "endOfRange": 10000 + } + ] + }, + { + "id": "exp_rollout_no_rollout_id", + "key": "rollout_no_rollout_id_experiment", + "status": "Running", + "layerId": "layer_4", + "audienceIds": [], + "forcedVariations": {}, + "type": "feature_rollout", + "variations": [ + { + "id": "var_no_rollout_1", + "key": "rollout_no_rollout_variation", + "featureEnabled": true + } + ], + "trafficAllocation": [ + { + "entityId": "var_no_rollout_1", + "endOfRange": 5000 + } + ] + } + ], + "featureFlags": [ + { + "id": "flag_1", + "key": "feature_with_rollout", + "rolloutId": "rollout_1", + "experimentIds": ["exp_rollout_1"], + "variables": [] + }, + { + "id": "flag_2", + "key": "feature_with_ab", + "rolloutId": "rollout_2", + "experimentIds": ["exp_ab_1"], + "variables": [] + }, + { + "id": "flag_3", + "key": "feature_no_rollout_id", + "rolloutId": "", + "experimentIds": ["exp_rollout_no_rollout_id"], + "variables": [] + } + ], + "rollouts": [ + { + "id": "rollout_1", + "experiments": [ + { + "id": "rollout_exp_1", + "key": "rollout_rule_1", + "status": "Running", + "layerId": "rollout_layer_1", + "audienceIds": [], + "forcedVariations": {}, + "variations": [ + { + "id": "rollout_var_1", + "key": "rollout_enabled", + "featureEnabled": true + } + ], + "trafficAllocation": [ + { + "entityId": "rollout_var_1", + "endOfRange": 10000 + } + ] + }, + { + "id": "rollout_exp_everyone", + "key": "everyone_else_rule", + "status": "Running", + "layerId": "rollout_layer_everyone", + "audienceIds": [], + "forcedVariations": {}, + "variations": [ + { + "id": "everyone_else_var", + "key": "everyone_else_variation", + "featureEnabled": false + } + ], + "trafficAllocation": [ + { + "entityId": "everyone_else_var", + "endOfRange": 10000 + } + ] + } + ] + }, + { + "id": "rollout_2", + "experiments": [ + { + "id": "rollout_exp_2", + "key": "rollout_rule_2", + "status": "Running", + "layerId": "rollout_layer_2", + "audienceIds": [], + "forcedVariations": {}, + "variations": [ + { + "id": "rollout_var_2", + "key": "rollout_variation_2", + "featureEnabled": true + } + ], + "trafficAllocation": [ + { + "entityId": "rollout_var_2", + "endOfRange": 10000 + } + ] + } + ] + } + ] +}