Skip to content

[FSSDK-11172] feat: update decision service to handle CMAB #602

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 22 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
b40adda
wip: cmab client done
muzahidul-opti Jun 18, 2025
9c9b9b0
Update test cases
muzahidul-opti Jun 18, 2025
d998e0b
wip: add test cases for cmab services
muzahidul-opti Jun 20, 2025
c427a46
Merge branch 'master' into muzahid/cmab-service
muzahidul-opti Jun 24, 2025
8e168f6
Added log for cmab decision
muzahidul-opti Jun 24, 2025
0c4d072
Update copyright date
muzahidul-opti Jun 24, 2025
f342ae2
CMAB decision implemented
muzahidul-opti Jun 26, 2025
ebbde3c
CmabService testcases added for sync getDecision function
muzahidul-opti Jun 27, 2025
92d3140
Add factory method for DefaultCmabService
muzahidul-opti Jun 27, 2025
c6507b0
DefaultDecision initializer updated
muzahidul-opti Jun 27, 2025
b51c228
Add operation type enum
muzahidul-opti Jun 27, 2025
e9d01ac
CMAB not supported in sync mode
muzahidul-opti Jun 27, 2025
e827b27
Reuse experiment bucketing logic
muzahidul-opti Jun 27, 2025
445006b
Add seperate method for group exlusion, add bucketToEntityId method
muzahidul-opti Jun 30, 2025
b9ce0f5
Update bucketing logic for cmab experiment
muzahidul-opti Jun 30, 2025
093e372
Add test cases for cmab experiement
muzahidul-opti Jun 30, 2025
f2cc7df
Add test cases for cmab decision options
muzahidul-opti Jul 2, 2025
f0c1d5b
Merge branch 'master' into muzahid/cmab-decision
muzahidul-opti Jul 2, 2025
384bfa8
Add test cases for bucketToEntity
muzahidul-opti Jul 3, 2025
87bcecf
Return feature dicision with nil variation for CMAB fetch error
muzahidul-opti Jul 4, 2025
d1067d6
Add code doc and fix linting issue
muzahidul-opti Jul 4, 2025
0d761c9
Update cmab entity id matching logic
muzahidul-opti Jul 4, 2025
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
18 changes: 18 additions & 0 deletions OptimizelySwiftSDK.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -2014,6 +2014,12 @@
98137C572A42BA0F004896EB /* OptimizelyUserContextTests_ODP_Aync_Await.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98137C562A42BA0F004896EB /* OptimizelyUserContextTests_ODP_Aync_Await.swift */; };
982C071F2D8C82AE0068B1FF /* HoldoutTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 982C071E2D8C82AE0068B1FF /* HoldoutTests.swift */; };
982C07202D8C82AE0068B1FF /* HoldoutTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 982C071E2D8C82AE0068B1FF /* HoldoutTests.swift */; };
9841590F2E13013E0042C01E /* OptimizelyUserContextTests_Decide_Async.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9841590E2E13013E0042C01E /* OptimizelyUserContextTests_Decide_Async.swift */; };
984159102E13013E0042C01E /* OptimizelyUserContextTests_Decide_Async.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9841590E2E13013E0042C01E /* OptimizelyUserContextTests_Decide_Async.swift */; };
984159122E141B640042C01E /* OptimizelyUserContextTests_Decide_CMAB.swift in Sources */ = {isa = PBXBuildFile; fileRef = 984159112E141B640042C01E /* OptimizelyUserContextTests_Decide_CMAB.swift */; };
984159132E141B640042C01E /* OptimizelyUserContextTests_Decide_CMAB.swift in Sources */ = {isa = PBXBuildFile; fileRef = 984159112E141B640042C01E /* OptimizelyUserContextTests_Decide_CMAB.swift */; };
984159372E16A7C50042C01E /* BucketTests_BucketToEntity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 984159362E16A7C50042C01E /* BucketTests_BucketToEntity.swift */; };
984159382E16A7C50042C01E /* BucketTests_BucketToEntity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 984159362E16A7C50042C01E /* BucketTests_BucketToEntity.swift */; };
984E2FDC2B27199B001F477A /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 987F11D92AF3F56F0083D3F9 /* PrivacyInfo.xcprivacy */; };
984E2FDD2B27199C001F477A /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 987F11D92AF3F56F0083D3F9 /* PrivacyInfo.xcprivacy */; };
984E2FDE2B27199D001F477A /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 987F11D92AF3F56F0083D3F9 /* PrivacyInfo.xcprivacy */; };
Expand Down Expand Up @@ -2569,6 +2575,9 @@
98137C542A41E86F004896EB /* OptimizelyClientTests_Init_Async_Await.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OptimizelyClientTests_Init_Async_Await.swift; sourceTree = "<group>"; };
98137C562A42BA0F004896EB /* OptimizelyUserContextTests_ODP_Aync_Await.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OptimizelyUserContextTests_ODP_Aync_Await.swift; sourceTree = "<group>"; };
982C071E2D8C82AE0068B1FF /* HoldoutTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HoldoutTests.swift; sourceTree = "<group>"; };
9841590E2E13013E0042C01E /* OptimizelyUserContextTests_Decide_Async.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OptimizelyUserContextTests_Decide_Async.swift; sourceTree = "<group>"; };
984159112E141B640042C01E /* OptimizelyUserContextTests_Decide_CMAB.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OptimizelyUserContextTests_Decide_CMAB.swift; sourceTree = "<group>"; };
984159362E16A7C50042C01E /* BucketTests_BucketToEntity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BucketTests_BucketToEntity.swift; sourceTree = "<group>"; };
984FE5102CC8AA88004F6F41 /* UserProfileTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfileTracker.swift; sourceTree = "<group>"; };
987F11D92AF3F56F0083D3F9 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
989428B22DBFA431008BA1C8 /* MockBucketer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockBucketer.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -3093,6 +3102,7 @@
6E75198F22C5211100B2B157 /* BucketTests_BucketVariation.swift */,
6E75198C22C5211100B2B157 /* BucketTests_ExpToVariation.swift */,
6E75198322C5211100B2B157 /* BucketTests_GroupToExp.swift */,
984159362E16A7C50042C01E /* BucketTests_BucketToEntity.swift */,
98AC98452DB7B762001405DD /* BucketTests_HoldoutToVariation.swift */,
6E75198422C5211100B2B157 /* BucketTests_Others.swift */,
6E75199622C5211100B2B157 /* DatafileHandlerTests.swift */,
Expand Down Expand Up @@ -3126,6 +3136,8 @@
6E75198122C5211100B2B157 /* OptimizelyErrorTests.swift */,
6EC6DD6824AE94820017D296 /* OptimizelyUserContextTests.swift */,
6E2D34B8250AD14000A0CDFE /* OptimizelyUserContextTests_Decide.swift */,
9841590E2E13013E0042C01E /* OptimizelyUserContextTests_Decide_Async.swift */,
984159112E141B640042C01E /* OptimizelyUserContextTests_Decide_CMAB.swift */,
98D5AE832DBB91C0000D5844 /* OptimizelyUserContextTests_Decide_Holdouts.swift */,
6E7E9B362523F8BF009E4426 /* OptimizelyUserContextTests_Decide_Reasons.swift */,
98AC985D2DBA6721001405DD /* OptimizelyUserContextTests_Decide_With_Holdouts_Reasons.swift */,
Expand Down Expand Up @@ -5005,13 +5017,15 @@
6E7518C122C520D400B2B157 /* Variable.swift in Sources */,
6E75170F22C520D400B2B157 /* OptimizelyClient.swift in Sources */,
6EC6DD4C24ABF89B0017D296 /* OptimizelyUserContext.swift in Sources */,
984159132E141B640042C01E /* OptimizelyUserContextTests_Decide_CMAB.swift in Sources */,
8464087D28130D3200CCF97D /* Integration.swift in Sources */,
6E7517E922C520D400B2B157 /* DefaultDecisionService.swift in Sources */,
989428C02DBFDCE5008BA1C8 /* DecisionListenerTest_Holdouts.swift in Sources */,
6E5D12282638DDF4000ABFC3 /* MockEventDispatcher.swift in Sources */,
6E9B116A22C5487100C22D81 /* BucketTests_Base.swift in Sources */,
6E9B115F22C5487100C22D81 /* MurmurTests.swift in Sources */,
6E9B116022C5487100C22D81 /* DecisionServiceTests_Experiments.swift in Sources */,
984159382E16A7C50042C01E /* BucketTests_BucketToEntity.swift in Sources */,
6E9B116322C5487100C22D81 /* BucketTests_GroupToExp.swift in Sources */,
6E7516AF22C520D400B2B157 /* DefaultLogger.swift in Sources */,
6EF8DE2524BD1BB2008B9488 /* OptimizelyDecideOption.swift in Sources */,
Expand All @@ -5025,6 +5039,7 @@
980CC9012D833F0D00E07D24 /* Holdout.swift in Sources */,
6EF8DE1724BD1BB2008B9488 /* OptimizelyDecision.swift in Sources */,
8428D3D12807337400D0FB0C /* LruCacheTests.swift in Sources */,
984159102E13013E0042C01E /* OptimizelyUserContextTests_Decide_Async.swift in Sources */,
84861816286D0B8900B7F41B /* VuidManagerTests.swift in Sources */,
6E7517C522C520D400B2B157 /* DefaultDatafileHandler.swift in Sources */,
6E75190922C520D500B2B157 /* Attribute.swift in Sources */,
Expand Down Expand Up @@ -5297,13 +5312,15 @@
84861800286CF33700B7F41B /* OdpEvent.swift in Sources */,
6EC6DD4524ABF89B0017D296 /* OptimizelyUserContext.swift in Sources */,
6E7517CB22C520D400B2B157 /* DefaultBucketer.swift in Sources */,
984159122E141B640042C01E /* OptimizelyUserContextTests_Decide_CMAB.swift in Sources */,
845945C1287758A000D13E11 /* OdpConfig.swift in Sources */,
8464087528130D3200CCF97D /* Integration.swift in Sources */,
989428C12DBFDCE5008BA1C8 /* DecisionListenerTest_Holdouts.swift in Sources */,
6E5D12202638DDF4000ABFC3 /* MockEventDispatcher.swift in Sources */,
6E9B115022C5486E00C22D81 /* BucketTests_Base.swift in Sources */,
6E9B114522C5486E00C22D81 /* MurmurTests.swift in Sources */,
6E9B114622C5486E00C22D81 /* DecisionServiceTests_Experiments.swift in Sources */,
984159372E16A7C50042C01E /* BucketTests_BucketToEntity.swift in Sources */,
6E9B114922C5486E00C22D81 /* BucketTests_GroupToExp.swift in Sources */,
6E75182B22C520D400B2B157 /* BatchEvent.swift in Sources */,
6EF8DE1E24BD1BB2008B9488 /* OptimizelyDecideOption.swift in Sources */,
Expand All @@ -5317,6 +5334,7 @@
6E75175D22C520D400B2B157 /* AtomicProperty.swift in Sources */,
6E7516D922C520D400B2B157 /* OPTUserProfileService.swift in Sources */,
6E7516E522C520D400B2B157 /* OPTEventDispatcher.swift in Sources */,
9841590F2E13013E0042C01E /* OptimizelyUserContextTests_Decide_Async.swift in Sources */,
98AC98462DB7B762001405DD /* BucketTests_HoldoutToVariation.swift in Sources */,
6E7E9B552523F8C6009E4426 /* OptimizelyUserContextTests_Decide_Legacy.swift in Sources */,
6E652305278E688B00954EA1 /* LruCache.swift in Sources */,
Expand Down
31 changes: 30 additions & 1 deletion Sources/CMAB/CmabService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,14 +30,18 @@ struct CmabCacheValue {
typealias CmabDecisionCompletionHandler = (Result<CmabDecision, Error>) -> Void

protocol CmabService {
func getDecision(config: ProjectConfig,
userContext: OptimizelyUserContext,
ruleId: String,
options: [OptimizelyDecideOption]) -> Result<CmabDecision, Error>
func getDecision(config: ProjectConfig,
userContext: OptimizelyUserContext,
ruleId: String,
options: [OptimizelyDecideOption],
completion: @escaping CmabDecisionCompletionHandler)
}

class DefaultCmabService {
class DefaultCmabService: CmabService {
typealias UserAttributes = [String : Any?]

private let cmabClient: CmabClient
Expand All @@ -49,6 +53,22 @@ class DefaultCmabService {
self.cmabCache = cmabCache
}

func getDecision(config: ProjectConfig,
userContext: OptimizelyUserContext,
ruleId: String,
options: [OptimizelyDecideOption]) -> Result<CmabDecision, Error> {
var result: Result<CmabDecision, Error>!
let semaphore = DispatchSemaphore(value: 0)
getDecision(config: config,
userContext: userContext,
ruleId: ruleId, options: options) { _result in
result = _result
semaphore.signal()
}
semaphore.wait()
return result
}

func getDecision(config: ProjectConfig,
userContext: OptimizelyUserContext,
ruleId: String,
Expand Down Expand Up @@ -155,3 +175,12 @@ class DefaultCmabService {
return filteredUserAttributes
}
}

extension DefaultCmabService {
static func createDefault() -> DefaultCmabService {
let DEFAULT_CMAB_CACHE_TIMEOUT = 30 * 60 * 1000 // 30 minutes in milliseconds
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like this timeout is too large - is this consistent for all SDKs? @raju-opti

let DEFAULT_CMAB_CACHE_SIZE = 1000
let cache = LruCache<String, CmabCacheValue>(size: DEFAULT_CMAB_CACHE_SIZE, timeoutInSecs: DEFAULT_CMAB_CACHE_TIMEOUT)
return DefaultCmabService(cmabClient: DefaultCmabClient(), cmabCache: cache)
}
}
3 changes: 3 additions & 0 deletions Sources/Data Model/Experiment.swift
Original file line number Diff line number Diff line change
Expand Up @@ -71,4 +71,7 @@ extension Experiment {
return status == .running
}

var isCmab: Bool {
return cmab != nil
}
}
126 changes: 88 additions & 38 deletions Sources/Implementation/DefaultBucketer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,43 +37,16 @@ class DefaultBucketer: OPTBucketer {
bucketingId: String) -> DecisionResponse<Variation> {
let reasons = DecisionReasons()

var mutexAllowed = true

// check for mutex

let group = config.project.groups.filter { $0.getExperiment(id: experiment.id) != nil }.first

if let group = group {
switch group.policy {
case .overlapping:
break
case .random:
let decisionResponse = bucketToExperiment(config: config,
group: group,
bucketingId: bucketingId)
reasons.merge(decisionResponse.reasons)
if let mutexExperiment = decisionResponse.result {
if mutexExperiment.id == experiment.id {
mutexAllowed = true

let info = LogMessage.userBucketedIntoExperimentInGroup(bucketingId, experiment.key, group.id)
logger.i(info)
reasons.addInfo(info)
} else {
mutexAllowed = false

let info = LogMessage.userNotBucketedIntoExperimentInGroup(bucketingId, experiment.key, group.id)
logger.i(info)
reasons.addInfo(info)
}
} else {
mutexAllowed = false

let info = LogMessage.userNotBucketedIntoAnyExperimentInGroup(bucketingId, group.id)
logger.i(info)
reasons.addInfo(info)
}
}
// Check mutex rules
let mutexAllowed = checkMutexRules(
config: config,
experiment: experiment,
bucketingId: bucketingId,
reasons: reasons
)

if !mutexAllowed {
return DecisionResponse(result: nil, reasons: reasons)
}

if !mutexAllowed { return DecisionResponse(result: nil, reasons: reasons) }
Comment on lines +48 to 52
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

clean up redundant?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, reuse the mutex logic.

Expand Down Expand Up @@ -120,6 +93,83 @@ class DefaultBucketer: OPTBucketer {
return DecisionResponse(result: nil, reasons: reasons)
}

/// Checks if an experiment is allowed to run based on mutex rules
/// - Parameters:
/// - config: The project configuration
/// - experiment: The experiment to check
/// - bucketingId: The bucketing ID for the user
/// - reasons: Decision reasons to track the mutex check process
/// - Returns: A boolean indicating if the experiment is allowed to run
private func checkMutexRules(
config: ProjectConfig,
experiment: Experiment,
bucketingId: String,
reasons: DecisionReasons
) -> Bool {
// Find the group containing this experiment
let group = config.project.groups.filter { $0.getExperiment(id: experiment.id) != nil }.first

guard let group = group else {
return true // No group found, experiment is allowed
}

switch group.policy {
case .overlapping:
return true // Overlapping experiments are always allowed

case .random:
let decisionResponse = bucketToExperiment(
config: config,
group: group,
bucketingId: bucketingId
)
reasons.merge(decisionResponse.reasons)

guard let mutexExperiment = decisionResponse.result else {
let info = LogMessage.userNotBucketedIntoAnyExperimentInGroup(bucketingId, group.id)
logger.i(info)
reasons.addInfo(info)
return false
}

let isAllowed = mutexExperiment.id == experiment.id
let info = isAllowed
? LogMessage.userBucketedIntoExperimentInGroup(bucketingId, experiment.key, group.id)
: LogMessage.userNotBucketedIntoExperimentInGroup(bucketingId, experiment.key, group.id)

logger.i(info)
reasons.addInfo(info)
return isAllowed
}
}

func bucketToEntityId(config: ProjectConfig,
experiment: Experiment,
bucketingId: String,
trafficAllocation: [TrafficAllocation]) -> DecisionResponse<String> {

let reasons = DecisionReasons()

// Check mutex rules
let mutexAllowed = checkMutexRules(
config: config,
experiment: experiment,
bucketingId: bucketingId,
reasons: reasons
)

if !mutexAllowed {
return DecisionResponse(result: nil, reasons: reasons)
}

let hashId = makeHashIdFromBucketingId(bucketingId: bucketingId, entityId: experiment.id)
let bucketValue = self.generateBucketValue(bucketingId: hashId)

let entityId = allocateTraffic(trafficAllocation: trafficAllocation, bucketValue: bucketValue)

return DecisionResponse(result: entityId, reasons: reasons)
}

func bucketToVariation(experiment: ExperimentCore,
bucketingId: String) -> DecisionResponse<Variation> {
let reasons = DecisionReasons()
Expand Down Expand Up @@ -153,7 +203,7 @@ class DefaultBucketer: OPTBucketer {
for bucket in trafficAllocation where bucketValue < bucket.endOfRange {
return bucket.entityId
}

return nil
}

Expand Down
Loading
Loading