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
42 changes: 37 additions & 5 deletions OptimizelySDK/Bucketing/DecisionService.cs
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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<FeatureDecision>.NewResult(holdoutDecision.ResultObject, reasons);
}
}

// Regular decision

// Get Bucketing ID from user attributes.
Expand Down Expand Up @@ -803,6 +819,22 @@ public virtual Result<FeatureDecision> 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<FeatureDecision>.NewResult(holdoutDecision.ResultObject, reasons);
}
}

var decisionResponse = GetVariation(experiment, user, config, options,
userProfileTracker);

Expand Down Expand Up @@ -894,9 +926,9 @@ public virtual Result<FeatureDecision> 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;
Expand All @@ -905,7 +937,7 @@ public virtual Result<FeatureDecision> 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<FeatureDecision>.NewResult(holdoutDecision.ResultObject, reasons);
}
}
Expand Down
22 changes: 16 additions & 6 deletions OptimizelySDK/Config/DatafileProjectConfig.cs
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -970,13 +970,23 @@ public string ToDatafile()
}

/// <summary>
/// Get holdout instances associated with the given feature flag Id.
/// Get all global holdouts that apply to all rules.
/// </summary>
/// <param name="flagId">Feature flag Id</param>
/// <returns>Array of holdouts associated with the flag, empty array if none</returns>
public Holdout[] GetHoldoutsForFlag(string flagId)
/// <returns>Array of global holdouts</returns>
public Holdout[] GetGlobalHoldouts()
{
var holdouts = _holdoutConfig?.GetHoldoutsForFlag(flagId);
var holdouts = _holdoutConfig?.GetGlobalHoldouts();
return holdouts?.ToArray() ?? new Holdout[0];
}

/// <summary>
/// Get local holdouts that apply to a specific rule.
/// </summary>
/// <param name="ruleId">Rule identifier</param>
/// <returns>Array of holdouts targeting this specific rule</returns>
public Holdout[] GetHoldoutsForRule(string ruleId)
{
var holdouts = _holdoutConfig?.GetHoldoutsForRule(ruleId);
return holdouts?.ToArray() ?? new Holdout[0];
}
/// Returns the datafile corresponding to ProjectConfig
Expand Down
17 changes: 11 additions & 6 deletions OptimizelySDK/Entity/Holdout.cs
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -35,14 +35,19 @@ public enum HoldoutStatus
}

/// <summary>
/// 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.
/// </summary>
public string[] IncludedFlags { get; set; } = new string[0];
public string[] IncludedRules { get; set; }

/// <summary>
/// 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).
/// </summary>
public string[] ExcludedFlags { get; set; } = new string[0];
public bool IsGlobal()
{
return IncludedRules == null;
}

/// <summary>
/// Layer ID is always empty for holdouts as they don't belong to any layer
Expand Down
16 changes: 11 additions & 5 deletions OptimizelySDK/ProjectConfig.cs
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -333,11 +333,17 @@ public interface ProjectConfig
Holdout GetHoldout(string holdoutId);

/// <summary>
/// Get holdout instances associated with the given feature flag Id.
/// Get all global holdouts that apply to all rules.
/// </summary>
/// <param name="flagKey">Feature flag Id</param>
/// <returns>Array of holdouts associated with the flag, empty array if none</returns>
Holdout[] GetHoldoutsForFlag(string flagId);
/// <returns>Array of global holdouts</returns>
Holdout[] GetGlobalHoldouts();

/// <summary>
/// Get local holdouts that apply to a specific rule.
/// </summary>
/// <param name="ruleId">Rule identifier</param>
/// <returns>Array of holdouts targeting this specific rule</returns>
Holdout[] GetHoldoutsForRule(string ruleId);

/// <summary>
/// Returns the datafile corresponding to ProjectConfig
Expand Down
110 changes: 34 additions & 76 deletions OptimizelySDK/Utils/HoldoutConfig.cs
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -21,16 +21,14 @@
namespace OptimizelySDK.Utils
{
/// <summary>
/// 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.
/// </summary>
public class HoldoutConfig
{
private List<Holdout> _allHoldouts;
private readonly List<Holdout> _globalHoldouts;
private readonly Dictionary<string, Holdout> _holdoutIdMap;
private readonly Dictionary<string, List<Holdout>> _includedHoldouts;
private readonly Dictionary<string, List<Holdout>> _excludedHoldouts;
private readonly Dictionary<string, List<Holdout>> _flagHoldoutCache;
private readonly Dictionary<string, List<Holdout>> _ruleHoldoutsMap;

/// <summary>
/// Initializes a new instance of the HoldoutConfig class.
Expand All @@ -41,9 +39,7 @@ public HoldoutConfig(Holdout[] allHoldouts = null)
_allHoldouts = allHoldouts?.ToList() ?? new List<Holdout>();
_globalHoldouts = new List<Holdout>();
_holdoutIdMap = new Dictionary<string, Holdout>();
_includedHoldouts = new Dictionary<string, List<Holdout>>();
_excludedHoldouts = new Dictionary<string, List<Holdout>>();
_flagHoldoutCache = new Dictionary<string, List<Holdout>>();
_ruleHoldoutsMap = new Dictionary<string, List<Holdout>>();

UpdateHoldoutMapping();
}
Expand All @@ -54,102 +50,64 @@ public HoldoutConfig(Holdout[] allHoldouts = null)
public IDictionary<string, Holdout> HoldoutIdMap => _holdoutIdMap;

/// <summary>
/// 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.
/// </summary>
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<Holdout>();

_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<Holdout>();
if (!_ruleHoldoutsMap.ContainsKey(ruleId))
_ruleHoldoutsMap[ruleId] = new List<Holdout>();

_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
}
}

/// <summary>
/// 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.
/// </summary>
/// <param name="flagId">The flag identifier</param>
/// <returns>A list of Holdout objects relevant to the given flag</returns>
public List<Holdout> GetHoldoutsForFlag(string flagId)
/// <returns>A list of global Holdout objects</returns>
public List<Holdout> GetGlobalHoldouts()
{
if (string.IsNullOrEmpty(flagId) || _allHoldouts.Count == 0)
return new List<Holdout>();

// Check cache first
if (_flagHoldoutCache.ContainsKey(flagId))
return _flagHoldoutCache[flagId];

var activeHoldouts = new List<Holdout>();
// Start with global holdouts, excluding any that are specifically excluded for this flag
var excludedForFlag = _excludedHoldouts.ContainsKey(flagId) ? _excludedHoldouts[flagId] : new List<Holdout>();

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<Holdout>(_globalHoldouts);
}

// Add included holdouts for this flag
if (_includedHoldouts.ContainsKey(flagId))
{
activeHoldouts.AddRange(_includedHoldouts[flagId]);
}
/// <summary>
/// Returns local holdouts that apply to a specific rule.
/// </summary>
/// <param name="ruleId">The rule identifier</param>
/// <returns>A list of Holdout objects that target this specific rule</returns>
public List<Holdout> GetHoldoutsForRule(string ruleId)
{
if (string.IsNullOrEmpty(ruleId))
return new List<Holdout>();

// Cache the result
_flagHoldoutCache[flagId] = activeHoldouts;
if (_ruleHoldoutsMap.ContainsKey(ruleId))
return new List<Holdout>(_ruleHoldoutsMap[ruleId]);

return activeHoldouts;
return new List<Holdout>();
}

/// <summary>
Expand Down
Loading