Skip to content
Merged
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
4 changes: 4 additions & 0 deletions Sources/Data Model/Holdout.swift
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,10 @@ extension Holdout {
return status == .running
}

/// True when this holdout applies to every flag (global scope).
/// Scope is set by the datafile section (`holdouts` vs `localHoldouts`);
/// HoldoutConfig strips `includedRules` from `holdouts`-section entries so
/// this property stays consistent with section membership.
var isGlobal: Bool {
return includedRules == nil
}
Expand Down
83 changes: 49 additions & 34 deletions Sources/Data Model/HoldoutConfig.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
//
// Copyright 2025, Optimizely, Inc. and contributors
// Copyright 2025-2026, Optimizely, Inc. and contributors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
Expand All @@ -16,58 +16,73 @@

import Foundation

/// Holds parsed holdout entries and exposes per-rule and global lookups.
///
/// Two top-level datafile sections drive holdout scoping:
/// - `holdouts` → global (applied to every flag); `includedRules` is stripped at parse time.
/// - `localHoldouts` → rule-scoped via `includedRules`; entries without rules are excluded.
struct HoldoutConfig {
var allHoldouts: [Holdout] {
didSet {
updateHoldoutMapping()
}
}
private let logger = OPTLoggerFactory.getLogger()

private(set) var global: [Holdout] = []
private(set) var holdoutIdMap: [String: Holdout] = [:]
private(set) var ruleHoldoutsMap: [String: [Holdout]] = [:]

init(allholdouts: [Holdout] = []) {
self.allHoldouts = allholdouts
updateHoldoutMapping()

// MARK: - Init

init() {}

init(globalHoldouts: [Holdout], localHoldouts: [Holdout]) {
applySections(globalHoldouts: globalHoldouts, localHoldouts: localHoldouts)
}

/// Updates internal mappings of holdouts including the id map, global list, and per-rule maps.
mutating func updateHoldoutMapping() {
holdoutIdMap = {
var map = [String: Holdout]()
allHoldouts.forEach { map[$0.id] = $0 }
return map
}()

global = []
ruleHoldoutsMap = [:]
// MARK: - Section application

for holdout in allHoldouts {
if holdout.isGlobal {
// includedRules == nil → global holdout
global.append(holdout)
} else {
// includedRules == [ruleId, ...] → local holdout
for ruleId in holdout.includedRules! {
ruleHoldoutsMap[ruleId, default: []].append(holdout)
}
private mutating func applySections(globalHoldouts: [Holdout], localHoldouts: [Holdout]) {
var newIdMap: [String: Holdout] = [:]
var newGlobal: [Holdout] = []
var newRuleMap: [String: [Holdout]] = [:]

for var holdout in globalHoldouts {
if holdout.includedRules != nil {
logger.w("Global holdout '\(holdout.key)' (id: \(holdout.id)) has 'includedRules' which will be ignored; global holdouts apply to all flags.")
}
holdout.includedRules = nil
newIdMap[holdout.id] = holdout
newGlobal.append(holdout)
}

// Local entries must carry a non-empty `includedRules`; invalid ones are excluded.
for holdout in localHoldouts {
guard let rules = holdout.includedRules, !rules.isEmpty else {
logger.e(
"Local holdout '\(holdout.key)' (id: \(holdout.id)) is missing or has empty 'includedRules'; skipping."
)
continue
}
newIdMap[holdout.id] = holdout
for ruleId in rules {
newRuleMap[ruleId, default: []].append(holdout)
}
}

self.holdoutIdMap = newIdMap
self.global = newGlobal
self.ruleHoldoutsMap = newRuleMap
}


// MARK: - Lookups

/// Returns local holdouts targeting a specific rule.
/// - Parameter ruleId: The rule identifier.
/// - Returns: An array of `Holdout` objects targeting the given rule.
func getHoldoutsForRule(ruleId: String) -> [Holdout] {
return ruleHoldoutsMap[ruleId] ?? []
}

/// Returns all global holdouts.
/// - Returns: An array of global `Holdout` objects.
func getGlobalHoldouts() -> [Holdout] {
return global
}

/// Get a Holdout object for an Id.
func getHoldout(id: String) -> Holdout? {
return holdoutIdMap[id]
Expand Down
13 changes: 9 additions & 4 deletions Sources/Data Model/Project.swift
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,9 @@ struct Project: Codable, Equatable {
var sendFlagDecisions: Bool?
var sdkKey: String?
var environmentKey: String?
// Holdouts
// Holdouts: `holdouts` = global, `localHoldouts` = rule-scoped.
var holdouts: [Holdout]
var localHoldouts: [Holdout]
// Region
var region: Region?
let logger = OPTLoggerFactory.getLogger()
Expand All @@ -65,7 +66,7 @@ struct Project: Codable, Equatable {
// V3
case anonymizeIP
// V4
case rollouts, integrations, typedAudiences, featureFlags, botFiltering, sendFlagDecisions, sdkKey, environmentKey, holdouts, region
case rollouts, integrations, typedAudiences, featureFlags, botFiltering, sendFlagDecisions, sdkKey, environmentKey, holdouts, localHoldouts, region
}

init(from decoder: Decoder) throws {
Expand Down Expand Up @@ -96,19 +97,23 @@ struct Project: Codable, Equatable {
environmentKey = try container.decodeIfPresent(String.self, forKey: .environmentKey)
// Holdouts - defaults to empty array if key is not present
holdouts = try container.decodeIfPresent([Holdout].self, forKey: .holdouts) ?? []
// Local holdouts (FSSDK-12760) - new top-level section; defaults to empty when absent
// so old datafiles continue to parse unchanged.
localHoldouts = try container.decodeIfPresent([Holdout].self, forKey: .localHoldouts) ?? []
// Region - defaults to US if not present
region = try container.decodeIfPresent(Region.self, forKey: .region)
}

// Required since logger is not equatable
static func == (lhs: Project, rhs: Project) -> Bool {
return lhs.version == rhs.version && lhs.projectId == rhs.projectId && lhs.experiments == rhs.experiments && lhs.holdouts == rhs.holdouts &&
lhs.localHoldouts == rhs.localHoldouts &&
lhs.audiences == rhs.audiences && lhs.groups == rhs.groups && lhs.attributes == rhs.attributes &&
lhs.accountId == rhs.accountId && lhs.events == rhs.events && lhs.revision == rhs.revision &&
lhs.anonymizeIP == rhs.anonymizeIP && lhs.rollouts == rhs.rollouts &&
lhs.integrations == rhs.integrations && lhs.typedAudiences == rhs.typedAudiences &&
lhs.featureFlags == rhs.featureFlags && lhs.botFiltering == rhs.botFiltering &&
lhs.sendFlagDecisions == rhs.sendFlagDecisions && lhs.sdkKey == rhs.sdkKey &&
lhs.featureFlags == rhs.featureFlags && lhs.botFiltering == rhs.botFiltering &&
lhs.sendFlagDecisions == rhs.sendFlagDecisions && lhs.sdkKey == rhs.sdkKey &&
lhs.environmentKey == rhs.environmentKey && lhs.region == rhs.region
}
}
Expand Down
11 changes: 10 additions & 1 deletion Sources/Data Model/ProjectConfig.swift
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,16 @@ class ProjectConfig {
// variation into any experiment with type == .featureRollout
injectFeatureRolloutVariations()

holdoutConfig.allHoldouts = project.holdouts
// Holdout sections are partitioned by the datafile (FSSDK-12760):
// - `holdouts` -> global holdouts (applied to every flag)
// - `localHoldouts` -> rule-scoped local holdouts
// HoldoutConfig enforces the partition: `includedRules` on global-section
// entries is stripped, and local-section entries without `includedRules`
// are logged and excluded.
holdoutConfig = HoldoutConfig(
globalHoldouts: project.holdouts,
localHoldouts: project.localHoldouts
)

self.experimentKeyMap = {
var map = [String: Experiment]()
Expand Down
32 changes: 14 additions & 18 deletions Tests/OptimizelyTests-Common/BatchEventBuilderTests_Events.swift
Original file line number Diff line number Diff line change
Expand Up @@ -497,24 +497,23 @@ extension BatchEventBuilderTests_Events {
try! optimizely.start(datafile: datafile)

let holdout: Holdout = try! OTUtils.model(from: sampleHoldout)
optimizely.config?.project.holdouts = [holdout]
optimizely.config?.holdoutConfig.allHoldouts = [holdout]

optimizely.config?.holdoutConfig = HoldoutConfig(globalHoldouts: [holdout], localHoldouts: [])

let exp = expectation(description: "Wait for event to dispatch")
let user = optimizely.createUserContext(userId: userId)
_ = user.decide(key: featureKey)

DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
exp.fulfill()
}

let result = XCTWaiter.wait(for: [exp], timeout: 0.2)
if result == XCTWaiter.Result.completed {
let event = getFirstEventJSON(client: optimizely)!
let visitor = (event["visitors"] as! Array<Dictionary<String, Any>>)[0]
let snapshot = (visitor["snapshots"] as! Array<Dictionary<String, Any>>)[0]
let decision = (snapshot["decisions"] as! Array<Dictionary<String, Any>>)[0]

let metaData = decision["metadata"] as! Dictionary<String, Any>
XCTAssertEqual(metaData["rule_type"] as! String, Constants.DecisionSource.holdout.rawValue)
XCTAssertEqual(metaData["rule_key"] as! String, "holdout_key")
Expand All @@ -524,9 +523,9 @@ extension BatchEventBuilderTests_Events {
} else {
XCTFail("No event found")
}

}

func testImpressionEvent_UserInHoldout_IncludedFlags() {
let eventDispatcher2 = MockEventDispatcher()
var optimizely: OptimizelyClient! = OptimizelyClient(sdkKey: "12345", eventDispatcher: eventDispatcher2)
Expand All @@ -535,15 +534,14 @@ extension BatchEventBuilderTests_Events {

var holdout: Holdout = try! OTUtils.model(from: sampleHoldout)
holdout.includedRules = ["10390977673"] // exp_no_audience rule in feature_1
optimizely.config?.project.holdouts = [holdout]
optimizely.config?.holdoutConfig.allHoldouts = [holdout]

optimizely.config?.holdoutConfig = HoldoutConfig(globalHoldouts: [], localHoldouts: [holdout])

let exp = expectation(description: "Wait for event to dispatch")

let user = optimizely.createUserContext(userId: userId)
_ = user.decide(key: featureKey)


// Add a delay before evaluating getFirstEventJSON
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
exp.fulfill() // Fulfill the expectation after the delay
Expand Down Expand Up @@ -577,8 +575,7 @@ extension BatchEventBuilderTests_Events {

var holdout: Holdout = try! OTUtils.model(from: sampleHoldout)
holdout.includedRules = [] // Empty array = local holdout targeting no rules (excludes feature_1)
optimizely.config?.project.holdouts = [holdout]
optimizely.config?.holdoutConfig.allHoldouts = [holdout]
optimizely.config?.holdoutConfig = HoldoutConfig(globalHoldouts: [], localHoldouts: [holdout])

let exp = expectation(description: "Wait for event to dispatch")

Expand Down Expand Up @@ -618,8 +615,7 @@ extension BatchEventBuilderTests_Events {
/// Set traffic allocation to gero
holdout.trafficAllocation[0].endOfRange = 0
holdout.includedRules = ["10390977673"] // exp_with_audience rule in feature_1
optimizely.config?.project.holdouts = [holdout]
optimizely.config?.holdoutConfig.allHoldouts = [holdout]
optimizely.config?.holdoutConfig = HoldoutConfig(globalHoldouts: [], localHoldouts: [holdout])

let exp = expectation(description: "Wait for event to dispatch")

Expand Down
11 changes: 4 additions & 7 deletions Tests/OptimizelyTests-Common/DecisionListenerTest_Holdouts.swift
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ class DecisionListenerTests_Holdouts: XCTestCase {
let mockDecisionService = DefaultDecisionService(userProfileService: OTUtils.createClearUserProfileService(), bucketer: MockBucketer(mockBucketValue: 400))
optimizely.decisionService = mockDecisionService
optimizely.config!.project.holdouts = [holdout]
optimizely.config!.holdoutConfig.allHoldouts = [holdout]
optimizely.config!.holdoutConfig = HoldoutConfig(globalHoldouts: [holdout], localHoldouts: [])

self.notificationCenter = self.optimizely.notificationCenter!
}
Expand Down Expand Up @@ -119,8 +119,7 @@ class DecisionListenerTests_Holdouts: XCTestCase {
var holdout = try! OTUtils.model(from: sampleHoldout) as Holdout
// Include all rules in feature_1: experiment + delivery rules
holdout.includedRules = ["10390977673", "3332020515", "3332020494", "18322080788"]
optimizely.config!.project.holdouts = [holdout]
optimizely.config!.holdoutConfig.allHoldouts = [holdout]
optimizely.config!.holdoutConfig = HoldoutConfig(globalHoldouts: [], localHoldouts: [holdout])

let exp = expectation(description: "x")
let user = optimizely.createUserContext(userId: kUserId, attributes: kAttributesCountryMatch)
Expand All @@ -142,8 +141,7 @@ class DecisionListenerTests_Holdouts: XCTestCase {
func testDecisionListenerDecideWithExcludedFlags() {
var holdout = try! OTUtils.model(from: sampleHoldout) as Holdout
holdout.includedRules = [] // Empty array = local holdout targeting no rules (excludes feature_1)
optimizely.config!.project.holdouts = [holdout]
optimizely.config!.holdoutConfig.allHoldouts = [holdout]
optimizely.config!.holdoutConfig = HoldoutConfig(globalHoldouts: [], localHoldouts: [holdout])

let exp = expectation(description: "x")
let user = optimizely.createUserContext(userId: kUserId, attributes: kAttributesCountryMatch)
Expand Down Expand Up @@ -172,8 +170,7 @@ class DecisionListenerTests_Holdouts: XCTestCase {
// Include all rules in feature_1: experiment + delivery rules
holdout_2.includedRules = ["10390977673", "3332020515", "3332020494", "18322080788"]

optimizely.config!.project.holdouts = [holdout, holdout_2]
optimizely.config!.holdoutConfig.allHoldouts = [holdout, holdout_2]
optimizely.config!.holdoutConfig = HoldoutConfig(globalHoldouts: [], localHoldouts: [holdout, holdout_2])

let exp = expectation(description: "x")
let user = optimizely.createUserContext(userId: kUserId, attributes: kAttributesCountryMatch)
Expand Down
Loading
Loading