Skip to content
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,4 +129,4 @@ Used to enforce Swift style and conventions.

- React - https://github.com/optimizely/react-sdk

- Ruby - https://github.com/optimizely/ruby-sdk
- Ruby - https://github.com/optimizely/ruby-sdk
2 changes: 1 addition & 1 deletion Sources/CMAB/CmabClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ enum CmabClientError: Error, Equatable {
}

struct CmabRetryConfig {
var maxRetries: Int = 3
var maxRetries: Int = 1
var initialBackoff: TimeInterval = 0.1 // seconds
var maxBackoff: TimeInterval = 10.0 // seconds
var backoffMultiplier: Double = 2.0
Expand Down
20 changes: 15 additions & 5 deletions Sources/CMAB/CmabService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -41,17 +41,22 @@ protocol CmabService {
completion: @escaping CmabDecisionCompletionHandler)
}

let DEFAULT_CMAB_CACHE_TIMEOUT = 30 * 60 // 30 minutes
let DEFAULT_CMAB_CACHE_SIZE = 100

typealias CmabCache = LruCache<String, CmabCacheValue>

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

private let cmabClient: CmabClient
private let cmabCache: LruCache<String, CmabCacheValue>
let cmabCache: CmabCache
private let logger = OPTLoggerFactory.getLogger()

private static let NUM_LOCKS = 1000
private let locks: [NSLock]

init(cmabClient: CmabClient, cmabCache: LruCache<String, CmabCacheValue>) {
init(cmabClient: CmabClient, cmabCache: CmabCache) {
self.cmabClient = cmabClient
self.cmabCache = cmabCache
self.locks = (0..<Self.NUM_LOCKS).map { _ in NSLock() }
Expand Down Expand Up @@ -193,9 +198,14 @@ class DefaultCmabService: CmabService {

extension DefaultCmabService {
static func createDefault() -> DefaultCmabService {
let DEFAULT_CMAB_CACHE_TIMEOUT = 30 * 60 * 1000 // 30 minutes in milliseconds
let DEFAULT_CMAB_CACHE_SIZE = 1000
let cache = LruCache<String, CmabCacheValue>(size: DEFAULT_CMAB_CACHE_SIZE, timeoutInSecs: DEFAULT_CMAB_CACHE_TIMEOUT)
let cache = CmabCache(size: DEFAULT_CMAB_CACHE_SIZE, timeoutInSecs: DEFAULT_CMAB_CACHE_TIMEOUT)
return DefaultCmabService(cmabClient: DefaultCmabClient(), cmabCache: cache)
}

static func createDefault(cacheSize: Int, cacheTimeout: Int) -> DefaultCmabService {
// if cache timeout is set to 0 or negative, use default timeout
let timeout = cacheTimeout <= 0 ? DEFAULT_CMAB_CACHE_TIMEOUT : cacheTimeout
let cache = CmabCache(size: cacheSize, timeoutInSecs: timeout)
return DefaultCmabService(cmabClient: DefaultCmabClient(), cmabCache: cache)
}
}
10 changes: 10 additions & 0 deletions Sources/ODP/OptimizelySdkSettings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ public struct OptimizelySdkSettings {
let timeoutForSegmentFetchInSecs: Int
/// The timeout in seconds of odp event dispatch - OS default timeout will be used if this is set to zero.
let timeoutForOdpEventInSecs: Int
/// The maximum size of cmab cache
let cmabCacheSize: Int
/// The timeout in seconds of cmab cache
let cmabCacheTimeoutInSecs: Int
/// ODP features are disabled if this is set to true.
let disableOdp: Bool
/// VUID is enabled if this is set to true.
Expand All @@ -37,6 +41,8 @@ public struct OptimizelySdkSettings {
/// - segmentsCacheTimeoutInSecs: The timeout in seconds of audience segments cache (optional. default = 600). Set to zero to disable timeout.
/// - timeoutForSegmentFetchInSecs: The timeout in seconds of odp segment fetch (optional. default = 10) - OS default timeout will be used if this is set to zero.
/// - timeoutForOdpEventInSecs: The timeout in seconds of odp event dispatch (optional. default = 10) - OS default timeout will be used if this is set to zero.
/// - cmabCacheSize: The maximum size of cmab cache (optional. default = 100).
/// - cmabCacheTimeoutInSecs: The timeout in seconds of amb cache (optional. default = 30 * 60).
/// - disableOdp: Set this flag to true (default = false) to disable ODP features
/// - enableVuid: Set this flag to true (default = false) to enable vuid.
/// - sdkName: Set this flag to override sdkName included in events
Expand All @@ -45,12 +51,16 @@ public struct OptimizelySdkSettings {
segmentsCacheTimeoutInSecs: Int = 600,
timeoutForSegmentFetchInSecs: Int = 10,
timeoutForOdpEventInSecs: Int = 10,
cmabCacheSize: Int = 100,
cmabCacheTimeoutInSecs: Int = 30 * 60,
disableOdp: Bool = false,
enableVuid: Bool = false,
sdkName: String? = nil,
sdkVersion: String? = nil) {
self.segmentsCacheSize = segmentsCacheSize
self.segmentsCacheTimeoutInSecs = segmentsCacheTimeoutInSecs
self.cmabCacheSize = cmabCacheSize
self.cmabCacheTimeoutInSecs = cmabCacheTimeoutInSecs
self.timeoutForSegmentFetchInSecs = timeoutForSegmentFetchInSecs
self.timeoutForOdpEventInSecs = timeoutForOdpEventInSecs
self.disableOdp = disableOdp
Expand Down
2 changes: 1 addition & 1 deletion Sources/Optimizely/OptimizelyClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ open class OptimizelyClient: NSObject {
let logger = logger ?? DefaultLogger()
type(of: logger).logLevel = defaultLogLevel ?? .info

let cmabService = DefaultCmabService.createDefault()
let cmabService = DefaultCmabService.createDefault(cacheSize: self.sdkSettings.cmabCacheSize, cacheTimeout: self.sdkSettings.cmabCacheTimeoutInSecs)

self.registerServices(sdkKey: sdkKey,
logger: logger,
Expand Down
18 changes: 16 additions & 2 deletions Tests/OptimizelyTests-APIs/OptimizelyClientTests_ODP.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,9 @@ class OptimizelyClientTests_ODP: XCTestCase {

func testSdkSettings_default() {
let optimizely = OptimizelyClient(sdkKey: OTUtils.randomSdkKey)

let cmabCache = ((optimizely.decisionService as! DefaultDecisionService).cmabService as! DefaultCmabService).cmabCache
XCTAssertEqual(100, cmabCache.maxSize)
XCTAssertEqual(30 * 60, cmabCache.timeoutInSecs)
XCTAssertEqual(100, optimizely.odpManager.segmentManager?.segmentsCache.maxSize)
XCTAssertEqual(600, optimizely.odpManager.segmentManager?.segmentsCache.timeoutInSecs)
XCTAssertEqual(10, optimizely.odpManager.segmentManager?.apiMgr.resourceTimeoutInSecs)
Expand All @@ -47,8 +49,13 @@ class OptimizelyClientTests_ODP: XCTestCase {

func testSdkSettings_custom() {
var sdkSettings = OptimizelySdkSettings(segmentsCacheSize: 12,
segmentsCacheTimeoutInSecs: 345)
segmentsCacheTimeoutInSecs: 345,
cmabCacheSize: 50,
cmabCacheTimeoutInSecs: 120)
var optimizely = OptimizelyClient(sdkKey: OTUtils.randomSdkKey, settings: sdkSettings)
var cmabCache = ((optimizely.decisionService as! DefaultDecisionService).cmabService as! DefaultCmabService).cmabCache
XCTAssertEqual(50, cmabCache.maxSize)
XCTAssertEqual(120, cmabCache.timeoutInSecs)
XCTAssertEqual(12, optimizely.odpManager.segmentManager?.segmentsCache.maxSize)
XCTAssertEqual(345, optimizely.odpManager.segmentManager?.segmentsCache.timeoutInSecs)

Expand All @@ -57,6 +64,13 @@ class OptimizelyClientTests_ODP: XCTestCase {
optimizely = OptimizelyClient(sdkKey: OTUtils.randomSdkKey, settings: sdkSettings)
XCTAssertEqual(34, optimizely.odpManager.segmentManager?.apiMgr.resourceTimeoutInSecs)
XCTAssertEqual(45, optimizely.odpManager.eventManager?.apiMgr.resourceTimeoutInSecs)

sdkSettings = OptimizelySdkSettings(cmabCacheSize: 50, cmabCacheTimeoutInSecs: -10)

optimizely = OptimizelyClient(sdkKey: OTUtils.randomSdkKey, settings: sdkSettings)
cmabCache = ((optimizely.decisionService as! DefaultDecisionService).cmabService as! DefaultCmabService).cmabCache
XCTAssertEqual(50, cmabCache.maxSize)
XCTAssertEqual(1800, cmabCache.timeoutInSecs)

sdkSettings = OptimizelySdkSettings(disableOdp: true)
optimizely = OptimizelyClient(sdkKey: OTUtils.randomSdkKey, settings: sdkSettings)
Expand Down
223 changes: 221 additions & 2 deletions Tests/OptimizelyTests-Common/CmabServiceTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ class MockUserContext: OptimizelyUserContext {
class DefaultCmabServiceTests: XCTestCase {
fileprivate var cmabClient: MockCmabClient!
fileprivate var config: MockProjectConfig!
var cmabCache: LruCache<String, CmabCacheValue>!
var cmabCache: CmabCache!
var cmabService: DefaultCmabService!
var userContext: OptimizelyUserContext!
let userAttributes: [String: Any] = ["age": 25, "location": "San Francisco"]
Expand All @@ -120,7 +120,7 @@ class DefaultCmabServiceTests: XCTestCase {
super.setUp()
config = MockProjectConfig()
cmabClient = MockCmabClient()
cmabCache = LruCache<String, CmabCacheValue>(size: 10, timeoutInSecs: 10)
cmabCache = CmabCache(size: 10, timeoutInSecs: 10)
cmabService = DefaultCmabService(cmabClient: cmabClient, cmabCache: cmabCache)
// Set up user context
userContext = MockUserContext(userId: "test-user", attributes: userAttributes)
Expand Down Expand Up @@ -657,3 +657,222 @@ extension DefaultCmabServiceTests {
}

}

extension DefaultCmabServiceTests {

func testCacheSizeZero() {
// Create a cache with size 0 (no caching)
let zeroCmabCache = CmabCache(size: 0, timeoutInSecs: 10)
let zeroCacheService = DefaultCmabService(cmabClient: cmabClient, cmabCache: zeroCmabCache)

cmabClient.fetchDecisionResult = .success("variation-first")

let expectation1 = self.expectation(description: "first request")

// First request
zeroCacheService.getDecision(
config: config,
userContext: userContext,
ruleId: "exp-123",
options: []
) { result in
switch result {
case .success(let decision):
XCTAssertEqual(decision.variationId, "variation-first")
XCTAssertTrue(self.cmabClient.fetchDecisionCalled, "Should call API on first request")

case .failure(let error):
XCTFail("Expected success but got error: \(error)")
}
expectation1.fulfill()
}

wait(for: [expectation1], timeout: 1.0)

// Reset and change the variation
cmabClient.reset()
cmabClient.fetchDecisionResult = .success("variation-second")

let expectation2 = self.expectation(description: "second request")

// Second request - should NOT use cache (size = 0)
zeroCacheService.getDecision(
config: config,
userContext: userContext,
ruleId: "exp-123",
options: []
) { result in
switch result {
case .success(let decision):
XCTAssertEqual(decision.variationId, "variation-second")
XCTAssertTrue(self.cmabClient.fetchDecisionCalled, "Should call API again when cache size is 0")

case .failure(let error):
XCTFail("Expected success but got error: \(error)")
}
expectation2.fulfill()
}

wait(for: [expectation2], timeout: 1.0)
}

func testCacheTimeoutZero() {
// When timeout is 0, LruCache uses default timeout (30 * 60 seconds = 1800 seconds)
// So cache will NOT expire within the test timeframe
let zeroTimeoutCache = CmabCache(size: 10, timeoutInSecs: 0)
let zeroTimeoutService = DefaultCmabService(cmabClient: cmabClient, cmabCache: zeroTimeoutCache)

cmabClient.fetchDecisionResult = .success("variation-first")

let expectation1 = self.expectation(description: "first request")

// First request
zeroTimeoutService.getDecision(
config: config,
userContext: userContext,
ruleId: "exp-123",
options: []
) { result in
switch result {
case .success(let decision):
XCTAssertEqual(decision.variationId, "variation-first")
XCTAssertTrue(self.cmabClient.fetchDecisionCalled, "Should call API on first request")

case .failure(let error):
XCTFail("Expected success but got error: \(error)")
}
expectation1.fulfill()
}

wait(for: [expectation1], timeout: 1.0)

// Reset client but don't change the result
cmabClient.reset()
cmabClient.fetchDecisionResult = .success("variation-second")

// Small delay (but not enough to expire default 1800s timeout)
Thread.sleep(forTimeInterval: 0.1)

let expectation2 = self.expectation(description: "second request")

// Second request - SHOULD use cache (timeout defaults to 1800s, not expired)
zeroTimeoutService.getDecision(
config: config,
userContext: userContext,
ruleId: "exp-123",
options: []
) { result in
switch result {
case .success(let decision):
// Should get cached value, not the new one
XCTAssertEqual(decision.variationId, "variation-first")
XCTAssertFalse(self.cmabClient.fetchDecisionCalled, "Should use cache when timeout defaults to 1800s")

case .failure(let error):
XCTFail("Expected success but got error: \(error)")
}
expectation2.fulfill()
}

wait(for: [expectation2], timeout: 1.0)
}

func testCacheSizeZeroAndTimeoutZero() {
// Create a cache with both size 0 and timeout 0 (completely disabled)
let disabledCache = CmabCache(size: 0, timeoutInSecs: 0)
let disabledCacheService = DefaultCmabService(cmabClient: cmabClient, cmabCache: disabledCache)

cmabClient.fetchDecisionResult = .success("variation-1")

let expectation1 = self.expectation(description: "first request")

// First request
disabledCacheService.getDecision(
config: config,
userContext: userContext,
ruleId: "exp-123",
options: []
) { result in
switch result {
case .success(let decision):
XCTAssertEqual(decision.variationId, "variation-1")

case .failure(let error):
XCTFail("Expected success but got error: \(error)")
}
expectation1.fulfill()
}

wait(for: [expectation1], timeout: 1.0)

let apiCallCount1 = cmabClient.fetchDecisionCalled ? 1 : 0

// Reset and make multiple requests
cmabClient.reset()
cmabClient.fetchDecisionResult = .success("variation-2")

let expectation2 = self.expectation(description: "second request")

disabledCacheService.getDecision(
config: config,
userContext: userContext,
ruleId: "exp-123",
options: []
) { result in
expectation2.fulfill()
}

wait(for: [expectation2], timeout: 1.0)

let apiCallCount2 = cmabClient.fetchDecisionCalled ? 1 : 0

XCTAssertEqual(apiCallCount1, 1, "First request should call API")
XCTAssertEqual(apiCallCount2, 1, "Second request should also call API (no caching)")
}

func testSyncCacheSizeZero() {
// Create a cache with size 0
let zeroCmabCache = CmabCache(size: 0, timeoutInSecs: 10)
let zeroCacheService = DefaultCmabService(cmabClient: cmabClient, cmabCache: zeroCmabCache)

cmabClient.fetchDecisionResult = .success("variation-first")

// First request
let result1 = zeroCacheService.getDecision(
config: config,
userContext: userContext,
ruleId: "exp-123",
options: []
)

switch result1 {
case .success(let decision):
XCTAssertEqual(decision.variationId, "variation-first")
XCTAssertTrue(cmabClient.fetchDecisionCalled, "Should call API on first request")

case .failure(let error):
XCTFail("Expected success but got error: \(error)")
}

// Reset and change variation
cmabClient.reset()
cmabClient.fetchDecisionResult = .success("variation-second")

// Second request - should NOT use cache
let result2 = zeroCacheService.getDecision(
config: config,
userContext: userContext,
ruleId: "exp-123",
options: []
)

switch result2 {
case .success(let decision):
XCTAssertEqual(decision.variationId, "variation-second")
XCTAssertTrue(cmabClient.fetchDecisionCalled, "Should call API again when cache size is 0")

case .failure(let error):
XCTFail("Expected success but got error: \(error)")
}
}
}
Loading
Loading