Skip to content

Commit 1099ba7

Browse files
[AI-FSSDK] [FSSDK-12337] Add Feature Rollout support (#400)
1 parent 4e3e182 commit 1099ba7

File tree

3 files changed

+376
-0
lines changed

3 files changed

+376
-0
lines changed

OptimizelySDK.Tests/ProjectConfigTest.cs

Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1490,5 +1490,252 @@ public void TestCmabFieldPopulation()
14901490

14911491
Assert.IsNull(experimentWithoutCmab.Cmab);
14921492
}
1493+
1494+
#region Feature Rollout Tests
1495+
1496+
private string BuildFeatureRolloutDatafile(
1497+
string experimentType = "fr",
1498+
string rolloutId = "rollout_1",
1499+
bool includeRollout = true,
1500+
bool includeRolloutVariations = true)
1501+
{
1502+
var rolloutExperiments = new List<object>();
1503+
if (includeRollout && includeRolloutVariations)
1504+
{
1505+
rolloutExperiments.Add(new
1506+
{
1507+
id = "rollout_rule_1",
1508+
key = "rollout_rule_1_key",
1509+
layerId = "rollout_layer_1",
1510+
status = "Running",
1511+
variations = new[]
1512+
{
1513+
new { id = "var_rr1", key = "variation_rr1", featureEnabled = true },
1514+
},
1515+
trafficAllocation = new[]
1516+
{
1517+
new { entityId = "var_rr1", endOfRange = 10000 },
1518+
},
1519+
audienceIds = new string[0],
1520+
forcedVariations = new Dictionary<string, string>(),
1521+
});
1522+
rolloutExperiments.Add(new
1523+
{
1524+
id = "rollout_everyone_else",
1525+
key = "rollout_everyone_else_key",
1526+
layerId = "rollout_layer_ee",
1527+
status = "Running",
1528+
variations = new[]
1529+
{
1530+
new
1531+
{
1532+
id = "var_ee", key = "variation_everyone_else",
1533+
featureEnabled = false,
1534+
},
1535+
},
1536+
trafficAllocation = new[]
1537+
{
1538+
new { entityId = "var_ee", endOfRange = 10000 },
1539+
},
1540+
audienceIds = new string[0],
1541+
forcedVariations = new Dictionary<string, string>(),
1542+
});
1543+
}
1544+
1545+
var rollouts = new List<object>();
1546+
if (includeRollout)
1547+
{
1548+
rollouts.Add(new
1549+
{
1550+
id = "rollout_1",
1551+
experiments = rolloutExperiments,
1552+
});
1553+
}
1554+
1555+
var experiments = new List<object>
1556+
{
1557+
new
1558+
{
1559+
id = "exp_ab",
1560+
key = "ab_experiment",
1561+
layerId = "layer_ab",
1562+
status = "Running",
1563+
variations = new[]
1564+
{
1565+
new { id = "var_ab_1", key = "variation_ab_1", featureEnabled = true },
1566+
},
1567+
trafficAllocation = new[]
1568+
{
1569+
new { entityId = "var_ab_1", endOfRange = 10000 },
1570+
},
1571+
audienceIds = new string[0],
1572+
forcedVariations = new Dictionary<string, string>(),
1573+
},
1574+
};
1575+
1576+
if (experimentType != null)
1577+
{
1578+
experiments.Add(new
1579+
{
1580+
id = "exp_rollout",
1581+
key = "rollout_experiment",
1582+
layerId = "layer_rollout",
1583+
status = "Running",
1584+
type = experimentType,
1585+
variations = new[]
1586+
{
1587+
new
1588+
{
1589+
id = "var_rollout_1", key = "variation_rollout_1",
1590+
featureEnabled = true,
1591+
},
1592+
},
1593+
trafficAllocation = new[]
1594+
{
1595+
new { entityId = "var_rollout_1", endOfRange = 5000 },
1596+
},
1597+
audienceIds = new string[0],
1598+
forcedVariations = new Dictionary<string, string>(),
1599+
});
1600+
}
1601+
1602+
var datafile = new
1603+
{
1604+
version = "4",
1605+
revision = "1",
1606+
projectId = "rollout_test",
1607+
accountId = "12345",
1608+
sdkKey = "test-key",
1609+
environmentKey = "production",
1610+
events = new object[0],
1611+
audiences = new object[0],
1612+
typedAudiences = new object[0],
1613+
attributes = new object[0],
1614+
groups = new object[0],
1615+
integrations = new object[0],
1616+
holdouts = new object[0],
1617+
experiments = experiments,
1618+
rollouts = rollouts,
1619+
featureFlags = new[]
1620+
{
1621+
new
1622+
{
1623+
id = "feature_1",
1624+
key = "feature_rollout_flag",
1625+
rolloutId = rolloutId,
1626+
experimentIds = experimentType != null
1627+
? new[] { "exp_ab", "exp_rollout" }
1628+
: new[] { "exp_ab" },
1629+
variables = new object[0],
1630+
},
1631+
},
1632+
};
1633+
1634+
return JsonConvert.SerializeObject(datafile);
1635+
}
1636+
1637+
[Test]
1638+
public void TestBackwardCompatibilityExperimentsWithoutTypeField()
1639+
{
1640+
var datafile = BuildFeatureRolloutDatafile();
1641+
var config = DatafileProjectConfig.Create(datafile, LoggerMock.Object,
1642+
ErrorHandlerMock.Object);
1643+
1644+
var abExperiment = config.GetExperimentFromKey("ab_experiment");
1645+
Assert.IsNull(abExperiment.Type);
1646+
}
1647+
1648+
[Test]
1649+
public void TestFeatureRolloutInjectionAddsEveryoneElseVariation()
1650+
{
1651+
var datafile = BuildFeatureRolloutDatafile();
1652+
var config = DatafileProjectConfig.Create(datafile, LoggerMock.Object,
1653+
ErrorHandlerMock.Object);
1654+
1655+
var rolloutExperiment = config.GetExperimentFromKey("rollout_experiment");
1656+
1657+
// Should have 2 variations: original + injected everyone else
1658+
Assert.AreEqual(2, rolloutExperiment.Variations.Length);
1659+
Assert.AreEqual("var_ee", rolloutExperiment.Variations[1].Id);
1660+
Assert.AreEqual("variation_everyone_else", rolloutExperiment.Variations[1].Key);
1661+
1662+
// Should have injected traffic allocation entry
1663+
var lastAllocation =
1664+
rolloutExperiment.TrafficAllocation[rolloutExperiment.TrafficAllocation.Length - 1];
1665+
Assert.AreEqual("var_ee", lastAllocation.EntityId);
1666+
Assert.AreEqual(10000, lastAllocation.EndOfRange);
1667+
}
1668+
1669+
[Test]
1670+
public void TestVariationMapsUpdatedWithInjectedVariation()
1671+
{
1672+
var datafile = BuildFeatureRolloutDatafile();
1673+
var config = DatafileProjectConfig.Create(datafile, LoggerMock.Object,
1674+
ErrorHandlerMock.Object);
1675+
1676+
var rolloutExperiment = config.GetExperimentFromKey("rollout_experiment");
1677+
1678+
// VariationKeyToVariationMap should contain the injected variation
1679+
Assert.IsTrue(
1680+
rolloutExperiment.VariationKeyToVariationMap.ContainsKey(
1681+
"variation_everyone_else"));
1682+
Assert.AreEqual("var_ee",
1683+
rolloutExperiment.VariationKeyToVariationMap["variation_everyone_else"].Id);
1684+
1685+
// VariationIdToVariationMap should contain the injected variation
1686+
Assert.IsTrue(
1687+
rolloutExperiment.VariationIdToVariationMap.ContainsKey("var_ee"));
1688+
1689+
// Global variation maps should also contain the injected variation
1690+
var variationByKey = config.GetVariationFromKey("rollout_experiment",
1691+
"variation_everyone_else");
1692+
Assert.AreEqual("var_ee", variationByKey.Id);
1693+
1694+
var variationById =
1695+
config.GetVariationFromId("rollout_experiment", "var_ee");
1696+
Assert.AreEqual("variation_everyone_else", variationById.Key);
1697+
}
1698+
1699+
[Test]
1700+
public void TestNonRolloutExperimentsNotModified()
1701+
{
1702+
var datafile = BuildFeatureRolloutDatafile();
1703+
var config = DatafileProjectConfig.Create(datafile, LoggerMock.Object,
1704+
ErrorHandlerMock.Object);
1705+
1706+
var abExperiment = config.GetExperimentFromKey("ab_experiment");
1707+
1708+
// A/B experiment should still have only 1 variation
1709+
Assert.AreEqual(1, abExperiment.Variations.Length);
1710+
Assert.AreEqual("var_ab_1", abExperiment.Variations[0].Id);
1711+
Assert.AreEqual(1, abExperiment.TrafficAllocation.Length);
1712+
}
1713+
1714+
[Test]
1715+
public void TestNoRolloutEdgeCaseSilentSkip()
1716+
{
1717+
var datafile = BuildFeatureRolloutDatafile(rolloutId: "");
1718+
var config = DatafileProjectConfig.Create(datafile, LoggerMock.Object,
1719+
ErrorHandlerMock.Object);
1720+
1721+
var rolloutExperiment = config.GetExperimentFromKey("rollout_experiment");
1722+
1723+
// Should still have only 1 variation (no injection)
1724+
Assert.AreEqual(1, rolloutExperiment.Variations.Length);
1725+
Assert.AreEqual("var_rollout_1", rolloutExperiment.Variations[0].Id);
1726+
}
1727+
1728+
[Test]
1729+
public void TestTypeFieldParsedCorrectly()
1730+
{
1731+
var datafile = BuildFeatureRolloutDatafile();
1732+
var config = DatafileProjectConfig.Create(datafile, LoggerMock.Object,
1733+
ErrorHandlerMock.Object);
1734+
1735+
var rolloutExperiment = config.GetExperimentFromKey("rollout_experiment");
1736+
Assert.AreEqual("fr", rolloutExperiment.Type);
1737+
}
1738+
1739+
#endregion
14931740
}
14941741
}

OptimizelySDK/Config/DatafileProjectConfig.cs

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -375,8 +375,26 @@ private void Initialize()
375375
}
376376
}
377377

378+
var validExperimentTypes = new HashSet<string>
379+
{
380+
Experiment.EXPERIMENT_TYPE_AB,
381+
Experiment.EXPERIMENT_TYPE_MAB,
382+
Experiment.EXPERIMENT_TYPE_CMAB,
383+
Experiment.EXPERIMENT_TYPE_TD,
384+
Experiment.EXPERIMENT_TYPE_FR,
385+
};
386+
378387
foreach (var experiment in _ExperimentIdMap.Values)
379388
{
389+
if (experiment.Type != null && !validExperimentTypes.Contains(experiment.Type))
390+
{
391+
Logger.Log(LogLevel.ERROR,
392+
$@"Experiment ""{experiment.Key}"" has invalid type ""{experiment.Type}"".");
393+
ErrorHandler.HandleError(
394+
new InvalidExperimentException(
395+
$"Invalid experiment type: {experiment.Type}"));
396+
}
397+
380398
_VariationKeyMap[experiment.Key] = new Dictionary<string, Variation>();
381399
_VariationIdMap[experiment.Key] = new Dictionary<string, Variation>();
382400
_VariationIdMapByExperimentId[experiment.Id] = new Dictionary<string, Variation>();
@@ -445,6 +463,73 @@ private void Initialize()
445463
}
446464
}
447465

466+
// Inject "everyone else" variation into feature_rollout experiments
467+
foreach (var feature in FeatureFlags)
468+
{
469+
var everyoneElseVariation = GetEveryoneElseVariation(feature);
470+
if (everyoneElseVariation == null)
471+
{
472+
continue;
473+
}
474+
475+
foreach (var experimentId in feature.ExperimentIds ?? new List<string>())
476+
{
477+
if (!_ExperimentIdMap.ContainsKey(experimentId))
478+
{
479+
continue;
480+
}
481+
482+
var experiment = _ExperimentIdMap[experimentId];
483+
if (experiment.Type != Experiment.EXPERIMENT_TYPE_FR)
484+
{
485+
continue;
486+
}
487+
488+
// Append the everyone else variation
489+
var variationsList = experiment.Variations?.ToList() ?? new List<Variation>();
490+
variationsList.Add(everyoneElseVariation);
491+
experiment.Variations = variationsList.ToArray();
492+
493+
// Append traffic allocation entry
494+
var trafficList = experiment.TrafficAllocation?.ToList() ??
495+
new List<TrafficAllocation>();
496+
trafficList.Add(new TrafficAllocation
497+
{
498+
EntityId = everyoneElseVariation.Id,
499+
EndOfRange = 10000,
500+
});
501+
experiment.TrafficAllocation = trafficList.ToArray();
502+
503+
// Regenerate variation key maps for this experiment
504+
experiment.GenerateVariationKeyMap();
505+
506+
// Update global variation maps
507+
if (_VariationKeyMap.ContainsKey(experiment.Key))
508+
{
509+
_VariationKeyMap[experiment.Key][everyoneElseVariation.Key] =
510+
everyoneElseVariation;
511+
}
512+
513+
if (_VariationIdMap.ContainsKey(experiment.Key))
514+
{
515+
_VariationIdMap[experiment.Key][everyoneElseVariation.Id] =
516+
everyoneElseVariation;
517+
}
518+
519+
if (_VariationKeyMapByExperimentId.ContainsKey(experiment.Id))
520+
{
521+
_VariationKeyMapByExperimentId[experiment.Id]
522+
[everyoneElseVariation.Key] = everyoneElseVariation;
523+
}
524+
525+
if (_VariationIdMapByExperimentId.ContainsKey(experiment.Id))
526+
{
527+
_VariationIdMapByExperimentId[experiment.Id]
528+
[everyoneElseVariation.Id] = everyoneElseVariation;
529+
}
530+
}
531+
}
532+
448533
var integration = Integrations.FirstOrDefault(i => i.Key.ToLower() == "odp");
449534
HostForOdp = integration?.Host;
450535
PublicKeyForOdp = integration?.PublicKey;
@@ -916,5 +1001,36 @@ public Holdout[] GetHoldoutsForFlag(string flagId)
9161001
/// </summary>
9171002
/// <returns>the datafile string corresponding to ProjectConfig</returns>
9181003
public string Region { get; set; }
1004+
1005+
/// <summary>
1006+
/// Get the "everyone else" variation from the last rule in the flag's rollout.
1007+
/// Returns null if the rollout cannot be resolved or has no variations.
1008+
/// </summary>
1009+
private Variation GetEveryoneElseVariation(FeatureFlag feature)
1010+
{
1011+
if (string.IsNullOrEmpty(feature.RolloutId))
1012+
{
1013+
return null;
1014+
}
1015+
1016+
if (!_RolloutIdMap.ContainsKey(feature.RolloutId))
1017+
{
1018+
return null;
1019+
}
1020+
1021+
var rollout = _RolloutIdMap[feature.RolloutId];
1022+
if (rollout.Experiments == null || rollout.Experiments.Count == 0)
1023+
{
1024+
return null;
1025+
}
1026+
1027+
var everyoneElseRule = rollout.Experiments[rollout.Experiments.Count - 1];
1028+
if (everyoneElseRule.Variations == null || everyoneElseRule.Variations.Length == 0)
1029+
{
1030+
return null;
1031+
}
1032+
1033+
return everyoneElseRule.Variations[0];
1034+
}
9191035
}
9201036
}

0 commit comments

Comments
 (0)