Skip to content

Commit 5baf45e

Browse files
[FSSDK-11172] feat: update decision service to handle CMAB (#602)
1 parent 123a198 commit 5baf45e

17 files changed

+1997
-156
lines changed

OptimizelySwiftSDK.xcodeproj/project.pbxproj

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2014,6 +2014,12 @@
20142014
98137C572A42BA0F004896EB /* OptimizelyUserContextTests_ODP_Aync_Await.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98137C562A42BA0F004896EB /* OptimizelyUserContextTests_ODP_Aync_Await.swift */; };
20152015
982C071F2D8C82AE0068B1FF /* HoldoutTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 982C071E2D8C82AE0068B1FF /* HoldoutTests.swift */; };
20162016
982C07202D8C82AE0068B1FF /* HoldoutTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 982C071E2D8C82AE0068B1FF /* HoldoutTests.swift */; };
2017+
9841590F2E13013E0042C01E /* OptimizelyUserContextTests_Decide_Async.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9841590E2E13013E0042C01E /* OptimizelyUserContextTests_Decide_Async.swift */; };
2018+
984159102E13013E0042C01E /* OptimizelyUserContextTests_Decide_Async.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9841590E2E13013E0042C01E /* OptimizelyUserContextTests_Decide_Async.swift */; };
2019+
984159122E141B640042C01E /* OptimizelyUserContextTests_Decide_CMAB.swift in Sources */ = {isa = PBXBuildFile; fileRef = 984159112E141B640042C01E /* OptimizelyUserContextTests_Decide_CMAB.swift */; };
2020+
984159132E141B640042C01E /* OptimizelyUserContextTests_Decide_CMAB.swift in Sources */ = {isa = PBXBuildFile; fileRef = 984159112E141B640042C01E /* OptimizelyUserContextTests_Decide_CMAB.swift */; };
2021+
984159372E16A7C50042C01E /* BucketTests_BucketToEntity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 984159362E16A7C50042C01E /* BucketTests_BucketToEntity.swift */; };
2022+
984159382E16A7C50042C01E /* BucketTests_BucketToEntity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 984159362E16A7C50042C01E /* BucketTests_BucketToEntity.swift */; };
20172023
984E2FDC2B27199B001F477A /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 987F11D92AF3F56F0083D3F9 /* PrivacyInfo.xcprivacy */; };
20182024
984E2FDD2B27199C001F477A /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 987F11D92AF3F56F0083D3F9 /* PrivacyInfo.xcprivacy */; };
20192025
984E2FDE2B27199D001F477A /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 987F11D92AF3F56F0083D3F9 /* PrivacyInfo.xcprivacy */; };
@@ -2569,6 +2575,9 @@
25692575
98137C542A41E86F004896EB /* OptimizelyClientTests_Init_Async_Await.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OptimizelyClientTests_Init_Async_Await.swift; sourceTree = "<group>"; };
25702576
98137C562A42BA0F004896EB /* OptimizelyUserContextTests_ODP_Aync_Await.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OptimizelyUserContextTests_ODP_Aync_Await.swift; sourceTree = "<group>"; };
25712577
982C071E2D8C82AE0068B1FF /* HoldoutTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HoldoutTests.swift; sourceTree = "<group>"; };
2578+
9841590E2E13013E0042C01E /* OptimizelyUserContextTests_Decide_Async.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OptimizelyUserContextTests_Decide_Async.swift; sourceTree = "<group>"; };
2579+
984159112E141B640042C01E /* OptimizelyUserContextTests_Decide_CMAB.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OptimizelyUserContextTests_Decide_CMAB.swift; sourceTree = "<group>"; };
2580+
984159362E16A7C50042C01E /* BucketTests_BucketToEntity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BucketTests_BucketToEntity.swift; sourceTree = "<group>"; };
25722581
984FE5102CC8AA88004F6F41 /* UserProfileTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfileTracker.swift; sourceTree = "<group>"; };
25732582
987F11D92AF3F56F0083D3F9 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
25742583
989428B22DBFA431008BA1C8 /* MockBucketer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockBucketer.swift; sourceTree = "<group>"; };
@@ -3093,6 +3102,7 @@
30933102
6E75198F22C5211100B2B157 /* BucketTests_BucketVariation.swift */,
30943103
6E75198C22C5211100B2B157 /* BucketTests_ExpToVariation.swift */,
30953104
6E75198322C5211100B2B157 /* BucketTests_GroupToExp.swift */,
3105+
984159362E16A7C50042C01E /* BucketTests_BucketToEntity.swift */,
30963106
98AC98452DB7B762001405DD /* BucketTests_HoldoutToVariation.swift */,
30973107
6E75198422C5211100B2B157 /* BucketTests_Others.swift */,
30983108
6E75199622C5211100B2B157 /* DatafileHandlerTests.swift */,
@@ -3126,6 +3136,8 @@
31263136
6E75198122C5211100B2B157 /* OptimizelyErrorTests.swift */,
31273137
6EC6DD6824AE94820017D296 /* OptimizelyUserContextTests.swift */,
31283138
6E2D34B8250AD14000A0CDFE /* OptimizelyUserContextTests_Decide.swift */,
3139+
9841590E2E13013E0042C01E /* OptimizelyUserContextTests_Decide_Async.swift */,
3140+
984159112E141B640042C01E /* OptimizelyUserContextTests_Decide_CMAB.swift */,
31293141
98D5AE832DBB91C0000D5844 /* OptimizelyUserContextTests_Decide_Holdouts.swift */,
31303142
6E7E9B362523F8BF009E4426 /* OptimizelyUserContextTests_Decide_Reasons.swift */,
31313143
98AC985D2DBA6721001405DD /* OptimizelyUserContextTests_Decide_With_Holdouts_Reasons.swift */,
@@ -5005,13 +5017,15 @@
50055017
6E7518C122C520D400B2B157 /* Variable.swift in Sources */,
50065018
6E75170F22C520D400B2B157 /* OptimizelyClient.swift in Sources */,
50075019
6EC6DD4C24ABF89B0017D296 /* OptimizelyUserContext.swift in Sources */,
5020+
984159132E141B640042C01E /* OptimizelyUserContextTests_Decide_CMAB.swift in Sources */,
50085021
8464087D28130D3200CCF97D /* Integration.swift in Sources */,
50095022
6E7517E922C520D400B2B157 /* DefaultDecisionService.swift in Sources */,
50105023
989428C02DBFDCE5008BA1C8 /* DecisionListenerTest_Holdouts.swift in Sources */,
50115024
6E5D12282638DDF4000ABFC3 /* MockEventDispatcher.swift in Sources */,
50125025
6E9B116A22C5487100C22D81 /* BucketTests_Base.swift in Sources */,
50135026
6E9B115F22C5487100C22D81 /* MurmurTests.swift in Sources */,
50145027
6E9B116022C5487100C22D81 /* DecisionServiceTests_Experiments.swift in Sources */,
5028+
984159382E16A7C50042C01E /* BucketTests_BucketToEntity.swift in Sources */,
50155029
6E9B116322C5487100C22D81 /* BucketTests_GroupToExp.swift in Sources */,
50165030
6E7516AF22C520D400B2B157 /* DefaultLogger.swift in Sources */,
50175031
6EF8DE2524BD1BB2008B9488 /* OptimizelyDecideOption.swift in Sources */,
@@ -5025,6 +5039,7 @@
50255039
980CC9012D833F0D00E07D24 /* Holdout.swift in Sources */,
50265040
6EF8DE1724BD1BB2008B9488 /* OptimizelyDecision.swift in Sources */,
50275041
8428D3D12807337400D0FB0C /* LruCacheTests.swift in Sources */,
5042+
984159102E13013E0042C01E /* OptimizelyUserContextTests_Decide_Async.swift in Sources */,
50285043
84861816286D0B8900B7F41B /* VuidManagerTests.swift in Sources */,
50295044
6E7517C522C520D400B2B157 /* DefaultDatafileHandler.swift in Sources */,
50305045
6E75190922C520D500B2B157 /* Attribute.swift in Sources */,
@@ -5297,13 +5312,15 @@
52975312
84861800286CF33700B7F41B /* OdpEvent.swift in Sources */,
52985313
6EC6DD4524ABF89B0017D296 /* OptimizelyUserContext.swift in Sources */,
52995314
6E7517CB22C520D400B2B157 /* DefaultBucketer.swift in Sources */,
5315+
984159122E141B640042C01E /* OptimizelyUserContextTests_Decide_CMAB.swift in Sources */,
53005316
845945C1287758A000D13E11 /* OdpConfig.swift in Sources */,
53015317
8464087528130D3200CCF97D /* Integration.swift in Sources */,
53025318
989428C12DBFDCE5008BA1C8 /* DecisionListenerTest_Holdouts.swift in Sources */,
53035319
6E5D12202638DDF4000ABFC3 /* MockEventDispatcher.swift in Sources */,
53045320
6E9B115022C5486E00C22D81 /* BucketTests_Base.swift in Sources */,
53055321
6E9B114522C5486E00C22D81 /* MurmurTests.swift in Sources */,
53065322
6E9B114622C5486E00C22D81 /* DecisionServiceTests_Experiments.swift in Sources */,
5323+
984159372E16A7C50042C01E /* BucketTests_BucketToEntity.swift in Sources */,
53075324
6E9B114922C5486E00C22D81 /* BucketTests_GroupToExp.swift in Sources */,
53085325
6E75182B22C520D400B2B157 /* BatchEvent.swift in Sources */,
53095326
6EF8DE1E24BD1BB2008B9488 /* OptimizelyDecideOption.swift in Sources */,
@@ -5317,6 +5334,7 @@
53175334
6E75175D22C520D400B2B157 /* AtomicProperty.swift in Sources */,
53185335
6E7516D922C520D400B2B157 /* OPTUserProfileService.swift in Sources */,
53195336
6E7516E522C520D400B2B157 /* OPTEventDispatcher.swift in Sources */,
5337+
9841590F2E13013E0042C01E /* OptimizelyUserContextTests_Decide_Async.swift in Sources */,
53205338
98AC98462DB7B762001405DD /* BucketTests_HoldoutToVariation.swift in Sources */,
53215339
6E7E9B552523F8C6009E4426 /* OptimizelyUserContextTests_Decide_Legacy.swift in Sources */,
53225340
6E652305278E688B00954EA1 /* LruCache.swift in Sources */,

Sources/CMAB/CmabService.swift

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,14 +30,18 @@ struct CmabCacheValue {
3030
typealias CmabDecisionCompletionHandler = (Result<CmabDecision, Error>) -> Void
3131

3232
protocol CmabService {
33+
func getDecision(config: ProjectConfig,
34+
userContext: OptimizelyUserContext,
35+
ruleId: String,
36+
options: [OptimizelyDecideOption]) -> Result<CmabDecision, Error>
3337
func getDecision(config: ProjectConfig,
3438
userContext: OptimizelyUserContext,
3539
ruleId: String,
3640
options: [OptimizelyDecideOption],
3741
completion: @escaping CmabDecisionCompletionHandler)
3842
}
3943

40-
class DefaultCmabService {
44+
class DefaultCmabService: CmabService {
4145
typealias UserAttributes = [String : Any?]
4246

4347
private let cmabClient: CmabClient
@@ -49,6 +53,22 @@ class DefaultCmabService {
4953
self.cmabCache = cmabCache
5054
}
5155

56+
func getDecision(config: ProjectConfig,
57+
userContext: OptimizelyUserContext,
58+
ruleId: String,
59+
options: [OptimizelyDecideOption]) -> Result<CmabDecision, Error> {
60+
var result: Result<CmabDecision, Error>!
61+
let semaphore = DispatchSemaphore(value: 0)
62+
getDecision(config: config,
63+
userContext: userContext,
64+
ruleId: ruleId, options: options) { _result in
65+
result = _result
66+
semaphore.signal()
67+
}
68+
semaphore.wait()
69+
return result
70+
}
71+
5272
func getDecision(config: ProjectConfig,
5373
userContext: OptimizelyUserContext,
5474
ruleId: String,
@@ -155,3 +175,12 @@ class DefaultCmabService {
155175
return filteredUserAttributes
156176
}
157177
}
178+
179+
extension DefaultCmabService {
180+
static func createDefault() -> DefaultCmabService {
181+
let DEFAULT_CMAB_CACHE_TIMEOUT = 30 * 60 * 1000 // 30 minutes in milliseconds
182+
let DEFAULT_CMAB_CACHE_SIZE = 1000
183+
let cache = LruCache<String, CmabCacheValue>(size: DEFAULT_CMAB_CACHE_SIZE, timeoutInSecs: DEFAULT_CMAB_CACHE_TIMEOUT)
184+
return DefaultCmabService(cmabClient: DefaultCmabClient(), cmabCache: cache)
185+
}
186+
}

Sources/Data Model/Experiment.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,4 +71,7 @@ extension Experiment {
7171
return status == .running
7272
}
7373

74+
var isCmab: Bool {
75+
return cmab != nil
76+
}
7477
}

Sources/Implementation/DefaultBucketer.swift

Lines changed: 88 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -37,43 +37,16 @@ class DefaultBucketer: OPTBucketer {
3737
bucketingId: String) -> DecisionResponse<Variation> {
3838
let reasons = DecisionReasons()
3939

40-
var mutexAllowed = true
41-
42-
// check for mutex
43-
44-
let group = config.project.groups.filter { $0.getExperiment(id: experiment.id) != nil }.first
45-
46-
if let group = group {
47-
switch group.policy {
48-
case .overlapping:
49-
break
50-
case .random:
51-
let decisionResponse = bucketToExperiment(config: config,
52-
group: group,
53-
bucketingId: bucketingId)
54-
reasons.merge(decisionResponse.reasons)
55-
if let mutexExperiment = decisionResponse.result {
56-
if mutexExperiment.id == experiment.id {
57-
mutexAllowed = true
58-
59-
let info = LogMessage.userBucketedIntoExperimentInGroup(bucketingId, experiment.key, group.id)
60-
logger.i(info)
61-
reasons.addInfo(info)
62-
} else {
63-
mutexAllowed = false
64-
65-
let info = LogMessage.userNotBucketedIntoExperimentInGroup(bucketingId, experiment.key, group.id)
66-
logger.i(info)
67-
reasons.addInfo(info)
68-
}
69-
} else {
70-
mutexAllowed = false
71-
72-
let info = LogMessage.userNotBucketedIntoAnyExperimentInGroup(bucketingId, group.id)
73-
logger.i(info)
74-
reasons.addInfo(info)
75-
}
76-
}
40+
// Check mutex rules
41+
let mutexAllowed = checkMutexRules(
42+
config: config,
43+
experiment: experiment,
44+
bucketingId: bucketingId,
45+
reasons: reasons
46+
)
47+
48+
if !mutexAllowed {
49+
return DecisionResponse(result: nil, reasons: reasons)
7750
}
7851

7952
if !mutexAllowed { return DecisionResponse(result: nil, reasons: reasons) }
@@ -120,6 +93,83 @@ class DefaultBucketer: OPTBucketer {
12093
return DecisionResponse(result: nil, reasons: reasons)
12194
}
12295

96+
/// Checks if an experiment is allowed to run based on mutex rules
97+
/// - Parameters:
98+
/// - config: The project configuration
99+
/// - experiment: The experiment to check
100+
/// - bucketingId: The bucketing ID for the user
101+
/// - reasons: Decision reasons to track the mutex check process
102+
/// - Returns: A boolean indicating if the experiment is allowed to run
103+
private func checkMutexRules(
104+
config: ProjectConfig,
105+
experiment: Experiment,
106+
bucketingId: String,
107+
reasons: DecisionReasons
108+
) -> Bool {
109+
// Find the group containing this experiment
110+
let group = config.project.groups.filter { $0.getExperiment(id: experiment.id) != nil }.first
111+
112+
guard let group = group else {
113+
return true // No group found, experiment is allowed
114+
}
115+
116+
switch group.policy {
117+
case .overlapping:
118+
return true // Overlapping experiments are always allowed
119+
120+
case .random:
121+
let decisionResponse = bucketToExperiment(
122+
config: config,
123+
group: group,
124+
bucketingId: bucketingId
125+
)
126+
reasons.merge(decisionResponse.reasons)
127+
128+
guard let mutexExperiment = decisionResponse.result else {
129+
let info = LogMessage.userNotBucketedIntoAnyExperimentInGroup(bucketingId, group.id)
130+
logger.i(info)
131+
reasons.addInfo(info)
132+
return false
133+
}
134+
135+
let isAllowed = mutexExperiment.id == experiment.id
136+
let info = isAllowed
137+
? LogMessage.userBucketedIntoExperimentInGroup(bucketingId, experiment.key, group.id)
138+
: LogMessage.userNotBucketedIntoExperimentInGroup(bucketingId, experiment.key, group.id)
139+
140+
logger.i(info)
141+
reasons.addInfo(info)
142+
return isAllowed
143+
}
144+
}
145+
146+
func bucketToEntityId(config: ProjectConfig,
147+
experiment: Experiment,
148+
bucketingId: String,
149+
trafficAllocation: [TrafficAllocation]) -> DecisionResponse<String> {
150+
151+
let reasons = DecisionReasons()
152+
153+
// Check mutex rules
154+
let mutexAllowed = checkMutexRules(
155+
config: config,
156+
experiment: experiment,
157+
bucketingId: bucketingId,
158+
reasons: reasons
159+
)
160+
161+
if !mutexAllowed {
162+
return DecisionResponse(result: nil, reasons: reasons)
163+
}
164+
165+
let hashId = makeHashIdFromBucketingId(bucketingId: bucketingId, entityId: experiment.id)
166+
let bucketValue = self.generateBucketValue(bucketingId: hashId)
167+
168+
let entityId = allocateTraffic(trafficAllocation: trafficAllocation, bucketValue: bucketValue)
169+
170+
return DecisionResponse(result: entityId, reasons: reasons)
171+
}
172+
123173
func bucketToVariation(experiment: ExperimentCore,
124174
bucketingId: String) -> DecisionResponse<Variation> {
125175
let reasons = DecisionReasons()
@@ -153,7 +203,7 @@ class DefaultBucketer: OPTBucketer {
153203
for bucket in trafficAllocation where bucketValue < bucket.endOfRange {
154204
return bucket.entityId
155205
}
156-
206+
157207
return nil
158208
}
159209

0 commit comments

Comments
 (0)