diff --git a/OptimizelySDK/Bucketing/DecisionService.cs b/OptimizelySDK/Bucketing/DecisionService.cs index 9b7f8785..d1a222ec 100644 --- a/OptimizelySDK/Bucketing/DecisionService.cs +++ b/OptimizelySDK/Bucketing/DecisionService.cs @@ -1,5 +1,5 @@ /* -* Copyright 2017-2022, 2024 Optimizely +* Copyright 2017-2022, 2024, 2026 Optimizely * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -686,6 +686,22 @@ ProjectConfig config reasons); } + // Check local holdouts targeting this specific rollout rule + var localHoldouts = config.GetHoldoutsForRule(rule.Id); + foreach (var holdout in localHoldouts) + { + var holdoutDecision = GetVariationForHoldout(holdout, user, config); + reasons += holdoutDecision.DecisionReasons; + + if (holdoutDecision.ResultObject != null) + { + Logger.Log(LogLevel.INFO, + reasons.AddInfo( + $"The user \"{userId}\" is bucketed into local holdout \"{holdout.Key}\" for rollout rule \"{rule.Key}\".")); + return Result.NewResult(holdoutDecision.ResultObject, reasons); + } + } + // Regular decision // Get Bucketing ID from user attributes. @@ -803,6 +819,22 @@ public virtual Result GetVariationForFeatureExperiment( } else { + // Check local holdouts targeting this specific rule/experiment + var localHoldouts = config.GetHoldoutsForRule(experiment.Id); + foreach (var holdout in localHoldouts) + { + var holdoutDecision = GetVariationForHoldout(holdout, user, config); + reasons += holdoutDecision.DecisionReasons; + + if (holdoutDecision.ResultObject != null) + { + Logger.Log(LogLevel.INFO, + reasons.AddInfo( + $"The user \"{userId}\" is bucketed into local holdout \"{holdout.Key}\" for rule \"{experiment.Key}\".")); + return Result.NewResult(holdoutDecision.ResultObject, reasons); + } + } + var decisionResponse = GetVariation(experiment, user, config, options, userProfileTracker); @@ -894,9 +926,9 @@ public virtual Result GetDecisionForFlag( var userId = user.GetUserId(); - // Check holdouts first (highest priority) - var holdouts = projectConfig.GetHoldoutsForFlag(featureFlag.Id); - foreach (var holdout in holdouts) + // Check global holdouts first (highest priority, evaluated at flag level) + var globalHoldouts = projectConfig.GetGlobalHoldouts(); + foreach (var holdout in globalHoldouts) { var holdoutDecision = GetVariationForHoldout(holdout, user, projectConfig); reasons += holdoutDecision.DecisionReasons; @@ -905,7 +937,7 @@ public virtual Result GetDecisionForFlag( { Logger.Log(LogLevel.INFO, reasons.AddInfo( - $"The user \"{userId}\" is bucketed into holdout \"{holdout.Key}\" for feature flag \"{featureFlag.Key}\".")); + $"The user \"{userId}\" is bucketed into global holdout \"{holdout.Key}\" for feature flag \"{featureFlag.Key}\".")); return Result.NewResult(holdoutDecision.ResultObject, reasons); } } diff --git a/OptimizelySDK/Config/DatafileProjectConfig.cs b/OptimizelySDK/Config/DatafileProjectConfig.cs index 4be4449a..77552b33 100644 --- a/OptimizelySDK/Config/DatafileProjectConfig.cs +++ b/OptimizelySDK/Config/DatafileProjectConfig.cs @@ -1,5 +1,5 @@ /* - * Copyright 2019-2023, Optimizely + * Copyright 2019-2023, 2026, Optimizely * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -970,13 +970,23 @@ public string ToDatafile() } /// - /// Get holdout instances associated with the given feature flag Id. + /// Get all global holdouts that apply to all rules. /// - /// Feature flag Id - /// Array of holdouts associated with the flag, empty array if none - public Holdout[] GetHoldoutsForFlag(string flagId) + /// Array of global holdouts + public Holdout[] GetGlobalHoldouts() { - var holdouts = _holdoutConfig?.GetHoldoutsForFlag(flagId); + var holdouts = _holdoutConfig?.GetGlobalHoldouts(); + return holdouts?.ToArray() ?? new Holdout[0]; + } + + /// + /// Get local holdouts that apply to a specific rule. + /// + /// Rule identifier + /// Array of holdouts targeting this specific rule + public Holdout[] GetHoldoutsForRule(string ruleId) + { + var holdouts = _holdoutConfig?.GetHoldoutsForRule(ruleId); return holdouts?.ToArray() ?? new Holdout[0]; } /// Returns the datafile corresponding to ProjectConfig diff --git a/OptimizelySDK/Entity/Holdout.cs b/OptimizelySDK/Entity/Holdout.cs index 834b5229..1b1e21ec 100644 --- a/OptimizelySDK/Entity/Holdout.cs +++ b/OptimizelySDK/Entity/Holdout.cs @@ -1,5 +1,5 @@ -/* - * Copyright 2025, Optimizely +/* + * Copyright 2025-2026, Optimizely * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -35,14 +35,19 @@ public enum HoldoutStatus } /// - /// Flags included in this holdout + /// Rule IDs included in this holdout. If null, this is a global holdout that applies to all rules. + /// If empty array, this is a local holdout with no rules (edge case). + /// If populated, this is a local holdout that applies only to the specified rules. /// - public string[] IncludedFlags { get; set; } = new string[0]; + public string[] IncludedRules { get; set; } /// - /// Flags excluded from this holdout + /// Returns true if this is a global holdout (applies to all rules), false if it's a local holdout (specific rules only). /// - public string[] ExcludedFlags { get; set; } = new string[0]; + public bool IsGlobal() + { + return IncludedRules == null; + } /// /// Layer ID is always empty for holdouts as they don't belong to any layer diff --git a/OptimizelySDK/ProjectConfig.cs b/OptimizelySDK/ProjectConfig.cs index 28f63d24..899f35fb 100644 --- a/OptimizelySDK/ProjectConfig.cs +++ b/OptimizelySDK/ProjectConfig.cs @@ -1,5 +1,5 @@ /* - * Copyright 2019-2022, Optimizely + * Copyright 2019-2022, 2026, Optimizely * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -333,11 +333,17 @@ public interface ProjectConfig Holdout GetHoldout(string holdoutId); /// - /// Get holdout instances associated with the given feature flag Id. + /// Get all global holdouts that apply to all rules. /// - /// Feature flag Id - /// Array of holdouts associated with the flag, empty array if none - Holdout[] GetHoldoutsForFlag(string flagId); + /// Array of global holdouts + Holdout[] GetGlobalHoldouts(); + + /// + /// Get local holdouts that apply to a specific rule. + /// + /// Rule identifier + /// Array of holdouts targeting this specific rule + Holdout[] GetHoldoutsForRule(string ruleId); /// /// Returns the datafile corresponding to ProjectConfig diff --git a/OptimizelySDK/Utils/HoldoutConfig.cs b/OptimizelySDK/Utils/HoldoutConfig.cs index 6b717af2..7756d04e 100644 --- a/OptimizelySDK/Utils/HoldoutConfig.cs +++ b/OptimizelySDK/Utils/HoldoutConfig.cs @@ -1,5 +1,5 @@ /* - * Copyright 2025, Optimizely + * Copyright 2025-2026, Optimizely * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -21,16 +21,14 @@ namespace OptimizelySDK.Utils { /// - /// Configuration manager for holdouts, providing flag-to-holdout relationship mapping and optimization logic. + /// Configuration manager for holdouts, providing rule-to-holdout relationship mapping and optimization logic. /// public class HoldoutConfig { private List _allHoldouts; private readonly List _globalHoldouts; private readonly Dictionary _holdoutIdMap; - private readonly Dictionary> _includedHoldouts; - private readonly Dictionary> _excludedHoldouts; - private readonly Dictionary> _flagHoldoutCache; + private readonly Dictionary> _ruleHoldoutsMap; /// /// Initializes a new instance of the HoldoutConfig class. @@ -41,9 +39,7 @@ public HoldoutConfig(Holdout[] allHoldouts = null) _allHoldouts = allHoldouts?.ToList() ?? new List(); _globalHoldouts = new List(); _holdoutIdMap = new Dictionary(); - _includedHoldouts = new Dictionary>(); - _excludedHoldouts = new Dictionary>(); - _flagHoldoutCache = new Dictionary>(); + _ruleHoldoutsMap = new Dictionary>(); UpdateHoldoutMapping(); } @@ -54,102 +50,64 @@ public HoldoutConfig(Holdout[] allHoldouts = null) public IDictionary HoldoutIdMap => _holdoutIdMap; /// - /// Updates internal mappings of holdouts including the id map, global list, and per-flag inclusion/exclusion maps. + /// Updates internal mappings of holdouts including the id map, global list, and per-rule mappings. /// private void UpdateHoldoutMapping() { // Clear existing mappings _holdoutIdMap.Clear(); _globalHoldouts.Clear(); - _includedHoldouts.Clear(); - _excludedHoldouts.Clear(); - _flagHoldoutCache.Clear(); + _ruleHoldoutsMap.Clear(); foreach (var holdout in _allHoldouts) { // Build ID mapping _holdoutIdMap[holdout.Id] = holdout; - var hasIncludedFlags = holdout.IncludedFlags != null && holdout.IncludedFlags.Length > 0; - var hasExcludedFlags = holdout.ExcludedFlags != null && holdout.ExcludedFlags.Length > 0; - - if (hasIncludedFlags) + if (holdout.IsGlobal()) { - // Local/targeted holdout - only applies to specific included flags - foreach (var flagId in holdout.IncludedFlags) - { - if (!_includedHoldouts.ContainsKey(flagId)) - _includedHoldouts[flagId] = new List(); - - _includedHoldouts[flagId].Add(holdout); - } + // Global holdout - applies to all rules + _globalHoldouts.Add(holdout); } - else + else if (holdout.IncludedRules != null && holdout.IncludedRules.Length > 0) { - // Global holdout (applies to all flags) - _globalHoldouts.Add(holdout); - - // If it has excluded flags, track which flags to exclude it from - if (hasExcludedFlags) + // Local holdout - applies only to specific rules + foreach (var ruleId in holdout.IncludedRules) { - foreach (var flagId in holdout.ExcludedFlags) - { - if (!_excludedHoldouts.ContainsKey(flagId)) - _excludedHoldouts[flagId] = new List(); + if (!_ruleHoldoutsMap.ContainsKey(ruleId)) + _ruleHoldoutsMap[ruleId] = new List(); - _excludedHoldouts[flagId].Add(holdout); - } + _ruleHoldoutsMap[ruleId].Add(holdout); } } + // Note: If IncludedRules is an empty array, it's a local holdout with no rules (edge case) + // It won't be added to either global or rule maps } } /// - /// 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 all global holdouts that apply to all rules. /// - /// The flag identifier - /// A list of Holdout objects relevant to the given flag - public List GetHoldoutsForFlag(string flagId) + /// A list of global Holdout objects + public List GetGlobalHoldouts() { - if (string.IsNullOrEmpty(flagId) || _allHoldouts.Count == 0) - return new List(); - - // Check cache first - if (_flagHoldoutCache.ContainsKey(flagId)) - return _flagHoldoutCache[flagId]; - - var activeHoldouts = new List(); - // Start with global holdouts, excluding any that are specifically excluded for this flag - var excludedForFlag = _excludedHoldouts.ContainsKey(flagId) ? _excludedHoldouts[flagId] : new List(); - - if (excludedForFlag.Count > 0) - { - // Only iterate if we have exclusions to check - foreach (var globalHoldout in _globalHoldouts) - { - if (!excludedForFlag.Contains(globalHoldout)) - { - activeHoldouts.Add(globalHoldout); - } - } - } - else - { - // No exclusions, add all global holdouts directly - activeHoldouts.AddRange(_globalHoldouts); - } + return new List(_globalHoldouts); + } - // Add included holdouts for this flag - if (_includedHoldouts.ContainsKey(flagId)) - { - activeHoldouts.AddRange(_includedHoldouts[flagId]); - } + /// + /// Returns local holdouts that apply to a specific rule. + /// + /// The rule identifier + /// A list of Holdout objects that target this specific rule + public List GetHoldoutsForRule(string ruleId) + { + if (string.IsNullOrEmpty(ruleId)) + return new List(); - // Cache the result - _flagHoldoutCache[flagId] = activeHoldouts; + if (_ruleHoldoutsMap.ContainsKey(ruleId)) + return new List(_ruleHoldoutsMap[ruleId]); - return activeHoldouts; + return new List(); } ///