Skip to content

Commit f342ae2

Browse files
CMAB decision implemented
1 parent 0c4d072 commit f342ae2

File tree

8 files changed

+308
-36
lines changed

8 files changed

+308
-36
lines changed

Sources/CMAB/CmabService.swift

Lines changed: 21 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,

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: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,54 @@ class DefaultBucketer: OPTBucketer {
120120
return DecisionResponse(result: nil, reasons: reasons)
121121
}
122122

123+
func bucketToEntityId(bucketingId: String,
124+
experiment: Experiment,
125+
trafficAllocation: [TrafficAllocation],
126+
group: Group?) -> DecisionResponse<String> {
127+
let reasons = DecisionReasons()
128+
129+
if let group = group, group.policy == .random {
130+
let hashId = makeHashIdFromBucketingId(bucketingId: bucketingId, entityId: group.id)
131+
let bucketValue = self.generateBucketValue(bucketingId: hashId)
132+
133+
var matched = false
134+
for allocation in group.trafficAllocation {
135+
if bucketValue < allocation.endOfRange {
136+
matched = true
137+
if allocation.entityId != experiment.id {
138+
let info = LogMessage.userNotBucketedIntoExperimentInGroup(bucketingId, experiment.key, group.id)
139+
reasons.addInfo(info)
140+
return DecisionResponse(result: nil, reasons: reasons)
141+
}
142+
143+
let info = LogMessage.userBucketedIntoExperimentInGroup(bucketingId, experiment.key, group.id)
144+
reasons.addInfo(info)
145+
break
146+
}
147+
}
148+
149+
if !matched {
150+
let info = LogMessage.userNotBucketedIntoAnyExperimentInGroup(bucketingId, group.id)
151+
reasons.addInfo(info)
152+
return DecisionResponse(result: nil, reasons: reasons)
153+
}
154+
}
155+
156+
let hashId = makeHashIdFromBucketingId(bucketingId: bucketingId, entityId: experiment.id)
157+
let bucketValue = self.generateBucketValue(bucketingId: hashId)
158+
159+
for allocation in trafficAllocation {
160+
if bucketValue < allocation.endOfRange {
161+
let info = LogMessage.userBucketedIntoEntity(allocation.entityId)
162+
reasons.addInfo(info)
163+
return DecisionResponse(result: allocation.entityId, reasons: reasons)
164+
}
165+
}
166+
let info = LogMessage.userNotBucketedIntoAnyEntity
167+
reasons.addInfo(info)
168+
return DecisionResponse(result: nil, reasons: reasons)
169+
}
170+
123171
func bucketToVariation(experiment: ExperimentCore,
124172
bucketingId: String) -> DecisionResponse<Variation> {
125173
let reasons = DecisionReasons()

Sources/Implementation/DefaultDecisionService.swift

Lines changed: 160 additions & 28 deletions
Large diffs are not rendered by default.

Sources/Optimizely+Decide/OptimizelyClient+Decide.swift

Lines changed: 68 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@
1616

1717
import Foundation
1818

19+
typealias DecideCompletion = (OptimizelyDecision) -> Void
20+
typealias DecideForKeysCompletion = ([String: OptimizelyDecision]) -> Void
21+
1922
extension OptimizelyClient {
2023

2124
/// Create a context of the user for which decision APIs will be called.
@@ -77,20 +80,56 @@ extension OptimizelyClient {
7780
var allOptions = defaultDecideOptions + (options ?? [])
7881
allOptions.removeAll(where: { $0 == .enabledFlagsOnly })
7982

80-
let decisionMap = decide(user: user, keys: [key], options: allOptions, ignoreDefaultOptions: true)
83+
let decisionMap = decide(user: user, keys: [key], options: allOptions, ignoreCmab: true, ignoreDefaultOptions: true)
8184
return decisionMap[key] ?? OptimizelyDecision.errorDecision(key: key, user: user, error: .generic)
8285
}
8386

87+
func decideAsync(user: OptimizelyUserContext,
88+
key: String,
89+
options: [OptimizelyDecideOption]? = nil,
90+
completion: @escaping DecideCompletion) {
91+
decisionQueue.async {
92+
guard let config = self.config else {
93+
let decision = OptimizelyDecision.errorDecision(key: key, user: user, error: .sdkNotReady)
94+
completion(decision)
95+
return
96+
}
97+
98+
guard let _ = config.getFeatureFlag(key: key) else {
99+
let decision = OptimizelyDecision.errorDecision(key: key, user: user, error: .featureKeyInvalid(key))
100+
completion(decision)
101+
return
102+
}
103+
104+
var allOptions = self.defaultDecideOptions + (options ?? [])
105+
allOptions.removeAll(where: { $0 == .enabledFlagsOnly })
106+
107+
let decisionMap = self.decide(user: user, keys: [key], options: allOptions, ignoreCmab: false, ignoreDefaultOptions: true)
108+
let decision = decisionMap[key] ?? OptimizelyDecision.errorDecision(key: key, user: user, error: .generic)
109+
completion(decision)
110+
}
111+
}
112+
84113
func decide(user: OptimizelyUserContext,
85114
keys: [String],
86115
options: [OptimizelyDecideOption]? = nil) -> [String: OptimizelyDecision] {
87-
return decide(user: user, keys: keys, options: options, ignoreDefaultOptions: false)
116+
return decide(user: user, keys: keys, options: options, ignoreCmab: true, ignoreDefaultOptions: false)
88117
}
89118

90-
func decide(user: OptimizelyUserContext,
119+
func decideAsync(user: OptimizelyUserContext,
91120
keys: [String],
92-
options: [OptimizelyDecideOption]? = nil,
93-
ignoreDefaultOptions: Bool) -> [String: OptimizelyDecision] {
121+
options: [OptimizelyDecideOption]? = nil, completion: @escaping DecideForKeysCompletion) {
122+
decisionQueue.async {
123+
let decisions = self.decide(user: user, keys: keys, options: options, ignoreCmab: false, ignoreDefaultOptions: false)
124+
completion(decisions)
125+
}
126+
}
127+
128+
private func decide(user: OptimizelyUserContext,
129+
keys: [String],
130+
options: [OptimizelyDecideOption]? = nil,
131+
ignoreCmab: Bool,
132+
ignoreDefaultOptions: Bool) -> [String: OptimizelyDecision] {
94133
guard let config = self.config else {
95134
logger.e(OptimizelyError.sdkNotReady)
96135
return [:]
@@ -132,7 +171,7 @@ extension OptimizelyClient {
132171
}
133172
}
134173

135-
let decisionList = (decisionService as? DefaultDecisionService)?.getVariationForFeatureList(config: config, featureFlags: flagsWithoutForceDecision, user: user, options: allOptions)
174+
let decisionList = (decisionService as? DefaultDecisionService)?.getVariationForFeatureList(config: config, featureFlags: flagsWithoutForceDecision, user: user, ignoreCmab: ignoreCmab, options: allOptions)
136175

137176
for index in 0..<flagsWithoutForceDecision.count {
138177
if decisionList?.indices.contains(index) ?? false {
@@ -166,6 +205,13 @@ extension OptimizelyClient {
166205
return decisionMap
167206
}
168207

208+
func decide(user: OptimizelyUserContext,
209+
keys: [String],
210+
options: [OptimizelyDecideOption]? = nil,
211+
ignoreDefaultOptions: Bool) -> [String: OptimizelyDecision] {
212+
return self.decide(user: user, keys: keys, options: options, ignoreCmab: true, ignoreDefaultOptions: ignoreDefaultOptions)
213+
}
214+
169215
private func createOptimizelyDecision(flagKey: String,
170216
user: OptimizelyUserContext,
171217
flagDecision: FeatureDecision?,
@@ -250,6 +296,22 @@ extension OptimizelyClient {
250296
return decide(user: user, keys: config.featureFlagKeys, options: options)
251297
}
252298

299+
func decideAllAsync(user: OptimizelyUserContext,
300+
options: [OptimizelyDecideOption]? = nil,
301+
completion: @escaping DecideForKeysCompletion) {
302+
303+
decisionQueue.async {
304+
guard let config = self.config else {
305+
self.logger.e(OptimizelyError.sdkNotReady)
306+
completion([:])
307+
return
308+
}
309+
310+
let decision = self.decide(user: user, keys: config.featureFlagKeys, options: options, ignoreCmab: false, ignoreDefaultOptions: false)
311+
completion(decision)
312+
}
313+
}
314+
253315
}
254316

255317
// MARK: - Utils

Sources/Optimizely/OptimizelyClient.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ open class OptimizelyClient: NSObject {
4848
}
4949

5050
let eventLock = DispatchQueue(label: "com.optimizely.client")
51+
let decisionQueue = DispatchQueue(label: "com.optimizely.decisionQueue")
5152

5253
// MARK: - Customizable Services
5354

Sources/Utils/LogMessage.swift

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@ enum LogMessage {
4747
case userHasNoForcedVariation(_ userId: String)
4848
case userHasNoForcedVariationForExperiment(_ userId: String, _ expKey: String)
4949
case userBucketedIntoVariationInExperiment(_ userId: String, _ expKey: String, _ varKey: String)
50+
case userBucketedIntoEntity(_ entityId: String)
51+
case userNotBucketedIntoAnyEntity
5052
case userBucketedIntoVariationInHoldout(_ userId: String, _ expKey: String, _ varKey: String)
5153
case userNotBucketedIntoVariation(_ userId: String)
5254
case userBucketedIntoInvalidVariation(_ id: String)
@@ -73,6 +75,7 @@ enum LogMessage {
7375
case failedToAssignValue
7476
case valueForKeyNotFound(_ key: String)
7577
case lowPeriodicDownloadInterval
78+
case cmabFetchFailed(_ expKey: String)
7679
}
7780

7881
extension LogMessage: CustomStringConvertible {
@@ -114,6 +117,8 @@ extension LogMessage: CustomStringConvertible {
114117
case .userHasNoForcedVariation(let userId): message = "User (\(userId)) is not in the forced variation map."
115118
case .userHasNoForcedVariationForExperiment(let userId, let expKey): message = "No experiment (\(expKey)) mapped to user (\(userId)) in the forced variation map."
116119
case .userBucketedIntoVariationInExperiment(let userId, let expKey, let varKey): message = "User (\(userId)) is in variation (\(varKey)) of experiment (\(expKey))"
120+
case .userBucketedIntoEntity(let entityId): message = "User bucketed into entity (\(entityId))"
121+
case .userNotBucketedIntoAnyEntity: message = "User not bucketed into any entity"
117122
case .userBucketedIntoVariationInHoldout(let userId, let holdoutKey, let varKey): message = "User (\(userId)) is in variation (\(varKey)) of holdout (\(holdoutKey))"
118123
case .userNotBucketedIntoVariation(let userId): message = "User (\(userId)) is in no variation."
119124
case .userNotBucketedIntoHoldoutVariation(let userId): message = "User (\(userId)) is in no holdout variation."
@@ -140,6 +145,7 @@ extension LogMessage: CustomStringConvertible {
140145
case .failedToAssignValue: message = "Value for path could not be assigned to provided type."
141146
case .valueForKeyNotFound(let key): message = "Value for JSON key (\(key)) not found."
142147
case .lowPeriodicDownloadInterval: message = "Polling intervals below 30 seconds are not recommended."
148+
case .cmabFetchFailed(let key): message = "Failed to fetch CMAB data for experiment: \(key)"
143149
}
144150

145151
return message

Tests/OptimizelyTests-Common/DecisionListenerTests.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1267,7 +1267,7 @@ class FakeDecisionService: DefaultDecisionService {
12671267
return DecisionResponse.responseNoReasons(result: featureDecision)
12681268
}
12691269

1270-
override func getVariationForFeatureExperiments(config: ProjectConfig, featureFlag: FeatureFlag, user: OptimizelyUserContext, userProfileTracker: UserProfileTracker? = nil, options: [OptimizelyDecideOption]? = nil) -> DecisionResponse<FeatureDecision> {
1270+
override func getVariationForFeatureExperiments(config: ProjectConfig, featureFlag: FeatureFlag, user: OptimizelyUserContext, userProfileTracker: UserProfileTracker? = nil, ignoreCmab: Bool = true, options: [OptimizelyDecideOption]? = nil) -> DecisionResponse<FeatureDecision> {
12711271
guard let experiment = self.experiment, let tmpVariation = self.variation else {
12721272
return DecisionResponse.nilNoReasons()
12731273
}

0 commit comments

Comments
 (0)