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 DevCycle.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
52A48707278C9BE200DABA34 /* Log.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52A48706278C9BE200DABA34 /* Log.swift */; };
52A96990279F3AAA00D3A602 /* PlatformDetails.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52A9698F279F3AAA00D3A602 /* PlatformDetails.swift */; };
52A96A0F27A0A7CF00D3A602 /* EventQueue.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52A96A0E27A0A7CF00D3A602 /* EventQueue.swift */; };
52D342B72E60A47700B86328 /* DVCConfigTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52D342B62E60A47300B86328 /* DVCConfigTests.swift */; };
52E693EA27567E2600B52375 /* DevCycleService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52E693E927567E2600B52375 /* DevCycleService.swift */; };
52E693ED2756816A00B52375 /* DVCConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52E693EC2756816A00B52375 /* DVCConfig.swift */; };
52E693F62758032500B52375 /* DevCycleServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52E693F52758032500B52375 /* DevCycleServiceTests.swift */; };
Expand Down Expand Up @@ -125,6 +126,7 @@
52A48706278C9BE200DABA34 /* Log.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Log.swift; sourceTree = "<group>"; };
52A9698F279F3AAA00D3A602 /* PlatformDetails.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlatformDetails.swift; sourceTree = "<group>"; };
52A96A0E27A0A7CF00D3A602 /* EventQueue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventQueue.swift; sourceTree = "<group>"; };
52D342B62E60A47300B86328 /* DVCConfigTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DVCConfigTests.swift; sourceTree = "<group>"; };
52E693E927567E2600B52375 /* DevCycleService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DevCycleService.swift; sourceTree = "<group>"; };
52E693EC2756816A00B52375 /* DVCConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DVCConfig.swift; sourceTree = "<group>"; };
52E693F52758032500B52375 /* DevCycleServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DevCycleServiceTests.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -203,6 +205,7 @@
5264A78C2768E87C00FEDB43 /* Models */ = {
isa = PBXGroup;
children = (
52D342B62E60A47300B86328 /* DVCConfigTests.swift */,
52E8C1F02DF87C560044783D /* DevCycleEventTests.swift */,
5268DB68275020D900D17A40 /* DevCycleUserTest.swift */,
524F4E61276D20A500CB9069 /* DVCVariableTests.swift */,
Expand Down Expand Up @@ -480,6 +483,7 @@
52A1139F27AB235C000B8285 /* EventQueueTests.swift in Sources */,
52133B2428DE0FEB0007691D /* GetTestConfig.swift in Sources */,
5256A99A2798716400E749FF /* ObjCDevCycleClientTests.m in Sources */,
52D342B72E60A47700B86328 /* DVCConfigTests.swift in Sources */,
5256A99B2798716400E749FF /* ObjcDevCycleUserTests.m in Sources */,
524F4E62276D20A600CB9069 /* DVCVariableTests.swift in Sources */,
);
Expand Down
24 changes: 12 additions & 12 deletions DevCycle/DevCycleClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,7 @@ public class DevCycleClient {
Log.error("Error getting config: \(error)", tags: ["setup"])

// If network failed but we have a cached config, don't return error
if self.config?.userConfig != nil {
if self.config?.getUserConfig() != nil {
Log.info("Using cached config due to network error")
finalError = nil
}
Expand Down Expand Up @@ -245,7 +245,7 @@ public class DevCycleClient {

private func updateUserConfig(_ config: UserConfig) {
let oldSSEURL = self.config?.userConfig?.sse?.url
self.config?.userConfig = config
self.config?.setUserConfig(config: config)

let newSSEURL = config.sse?.url
if newSSEURL != nil && oldSSEURL != newSSEURL {
Expand All @@ -260,7 +260,7 @@ public class DevCycleClient {
return
}

guard let sseURL = self.config?.userConfig?.sse?.url else {
guard let sseURL = self.config?.getUserConfig()?.sse?.url else {
Log.error("No SSE URL in config")
return
}
Expand All @@ -275,7 +275,7 @@ public class DevCycleClient {
self.sseConnection = nil
}

if let inactivityDelay = self.config?.userConfig?.sse?.inactivityDelay {
if let inactivityDelay = self.config?.getUserConfig()?.sse?.inactivityDelay {
self.inactivityDelayMS = Double(inactivityDelay)
}
self.sseConnection = SSEConnection(
Expand All @@ -297,7 +297,7 @@ public class DevCycleClient {
}
let sseMessage = try SSEMessage(from: messageDictionary)
if sseMessage.data.type == nil || sseMessage.data.type == "refetchConfig" {
if self?.config?.userConfig?.etag == nil
if self?.config?.getUserConfig()?.etag == nil
|| sseMessage.data.etag != self?.config?.userConfig?.etag
{
self?.refetchConfig(
Expand Down Expand Up @@ -395,7 +395,7 @@ public class DevCycleClient {
{
variable = variableFromDictionary
} else {
if let config = self.config?.userConfig,
if let config = self.config?.getUserConfig(),
let variableFromApi = config.variables[key]
{
variable = DVCVariable(from: variableFromApi, defaultValue: defaultValue)
Expand Down Expand Up @@ -462,12 +462,12 @@ public class DevCycleClient {

// Try to use cached config for the new user
// If we have a cached config, proceed without error
if self.useCachedConfigForUser(user: updateUser), self.config?.userConfig != nil {
if self.useCachedConfigForUser(user: updateUser), self.config?.getUserConfig() != nil {
Log.info(
"Using cached config for identifyUser due to network error: \(error)",
tags: ["identify"])
self.user = user
callback?(nil, self.config?.userConfig?.variables)
callback?(nil, self.config?.getUserConfig()?.variables)
return
} else {
// No cached config available, return error and don't change client state
Expand All @@ -482,7 +482,7 @@ public class DevCycleClient {
Log.debug("IdentifyUser config: \(config)", tags: ["identify"])
self.updateUserConfig(config)
self.user = user
callback?(nil, self.config?.userConfig?.variables)
callback?(nil, self.config?.getUserConfig()?.variables)
} else {
Log.error("No config returned for identifyUser", tags: ["identify"])
callback?(ClientError.ConfigFetchFailed, nil)
Expand Down Expand Up @@ -564,11 +564,11 @@ public class DevCycleClient {
}

public func allFeatures() -> [String: Feature] {
return self.config?.userConfig?.features ?? [:]
return self.config?.getUserConfig()?.features ?? [:]
}

public func allVariables() -> [String: Variable] {
return self.config?.userConfig?.variables ?? [:]
return self.config?.getUserConfig()?.variables ?? [:]
}

public func track(_ event: DevCycleEvent) {
Expand Down Expand Up @@ -738,7 +738,7 @@ public class DevCycleClient {
if options?.disableConfigCache != true,
let cachedConfig = cacheService.getConfig(user: user)
{
self.config?.userConfig = cachedConfig
self.config?.setUserConfig(config: cachedConfig)
Log.debug("Loaded config from cache for user_id \(String(describing: user.userId))")
return true
}
Expand Down
33 changes: 25 additions & 8 deletions DevCycle/Models/DVCConfig.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,31 @@ import Foundation
public class DVCConfig {
var sdkKey: String
var user: DevCycleUser
var userConfig: UserConfig? {
didSet {
if let userConfig = self.userConfig {
private let userConfigQueue = DispatchQueue(label: "com.devcycle.userConfigQueue")
var userConfig: UserConfig?

init(sdkKey: String, user: DevCycleUser) {
self.sdkKey = sdkKey
self.user = user
}

func getUserConfig() -> UserConfig? {
return userConfigQueue.sync {
return self.userConfig
}
}

func setUserConfig(config: UserConfig?) {
var configToNotify: UserConfig?

userConfigQueue.sync {
self.userConfig = config
configToNotify = config
}

// Post notification outside of the lock to avoid potential deadlocks
if let userConfig = configToNotify {
DispatchQueue.main.async {
NotificationCenter.default.post(
name: Notification.Name(NotificationNames.NewUserConfig),
object: self,
Expand All @@ -20,9 +42,4 @@ public class DVCConfig {
}
}
}

init(sdkKey: String, user: DevCycleUser) {
self.sdkKey = sdkKey
self.user = user
}
}
197 changes: 197 additions & 0 deletions DevCycleTests/Models/DVCConfigTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
//
// DVCConfigTests.swift
// DevCycleTests
//
//

import XCTest
@testable import DevCycle

final class DVCConfigTests: XCTestCase {

var mockUser: DevCycleUser!

override func setUp() {
super.setUp()
mockUser = try! DevCycleUser.builder()
.userId("test-user")
.isAnonymous(false)
.build()
}

override func tearDown() {
mockUser = nil
super.tearDown()
}

func getMockUserConfig(config: String = "test_config_eval_reason") throws -> UserConfig {
let data = getConfigData(name: config)
let dictionary = try JSONSerialization.jsonObject(with: data, options: .fragmentsAllowed) as! [String:Any]
return try UserConfig(from: dictionary)
}

func testConcurrentConfigAccess() throws {
let testConfig = DVCConfig(sdkKey: "test-sdk-key", user: mockUser)

let expectation = XCTestExpectation(description: "Concurrent config access completed")
expectation.expectedFulfillmentCount = 100 // 50 reads + 50 writes

let concurrentQueue = DispatchQueue(label: "test.concurrent", attributes: .concurrent)

// Test concurrent reads
for _ in 0..<50 {
concurrentQueue.async {
let _ = testConfig.getUserConfig()
expectation.fulfill()
}
}

let evalConfig = try self.getMockUserConfig(config: "test_config_eval_reason")

// Test concurrent writes
for _ in 0..<50 {
concurrentQueue.async {
testConfig.setUserConfig(config: evalConfig)
expectation.fulfill()
}
}

wait(for: [expectation], timeout: 10.0)

// Verify the final state is consistent
let finalConfig = testConfig.getUserConfig()
XCTAssertNotNil(finalConfig)
}

func testConcurrentConfigAccessWithVariables() throws {
let testConfig = DVCConfig(sdkKey: "test-sdk-key", user: mockUser)
let mockUserConfig = try getMockUserConfig()
testConfig.setUserConfig(config: mockUserConfig)

let expectation = XCTestExpectation(description: "Concurrent variable access completed")
expectation.expectedFulfillmentCount = 200 // 100 reads + 100 writes

let concurrentQueue = DispatchQueue(label: "test.concurrent.variables", attributes: .concurrent)

// Test concurrent reads of variables
for _ in 0..<100 {
concurrentQueue.async {
let variables = testConfig.getUserConfig()?.variables
XCTAssertNotNil(variables)
expectation.fulfill()
}
}
let evalConfig = try self.getMockUserConfig(config: "test_config_eval_reason")

// Test concurrent writes with new configs
for _ in 0..<100 {
concurrentQueue.async {
testConfig.setUserConfig(config: evalConfig)
expectation.fulfill()
}
}

wait(for: [expectation], timeout: 10.0)

// Verify final state
let finalConfig = testConfig.getUserConfig()
XCTAssertNotNil(finalConfig)
XCTAssertNotNil(finalConfig?.variables)
}

func testRapidConfigUpdates() throws {
let testConfig = DVCConfig(sdkKey: "test-sdk-key", user: mockUser)

let expectation = XCTestExpectation(description: "Rapid config updates completed")
expectation.expectedFulfillmentCount = 1000

let concurrentQueue = DispatchQueue(label: "test.rapid", attributes: .concurrent)
let evalConfig = try self.getMockUserConfig(config: "test_config_eval_reason")

// Rapidly update config from multiple threads
for _ in 0..<1000 {
concurrentQueue.async {
testConfig.setUserConfig(config: evalConfig)
expectation.fulfill()
}
}

wait(for: [expectation], timeout: 10.0)

// Verify no crashes occurred
let finalConfig = testConfig.getUserConfig()
XCTAssertNotNil(finalConfig)
}

func testConfigAccessDuringUpdates() throws {

let testConfig = DVCConfig(sdkKey: "test-sdk-key", user: mockUser)
testConfig.setUserConfig(config: try self.getMockUserConfig())

let expectation = XCTestExpectation(description: "Config access during updates completed")
expectation.expectedFulfillmentCount = 500 // 250 updates + 250 reads

let updateQueue = DispatchQueue(label: "test.updates", attributes: .concurrent)
let readQueue = DispatchQueue(label: "test.reads", attributes: .concurrent)

let evalConfig = try self.getMockUserConfig(config: "test_config_eval_reason")

// Continuously update config
for _ in 0..<250 {
updateQueue.async {
testConfig.setUserConfig(config: evalConfig)
expectation.fulfill()
}
}

// Continuously read config while updates are happening
for _ in 0..<250 {
readQueue.async {
let config = testConfig.getUserConfig()
_ = config?.variables
_ = config?.features
XCTAssertNotNil(config)
expectation.fulfill()
}
}

wait(for: [expectation], timeout: 10.0)

// Verify final state
let finalConfig = testConfig.getUserConfig()
XCTAssertNotNil(finalConfig)
}

func testSetConfigNil() throws {
let testNilConfig = DVCConfig(sdkKey: "test-sdk-key", user: mockUser)
testNilConfig.setUserConfig(config: try self.getMockUserConfig())

let expectation = XCTestExpectation(description: "Config access during setting config to nil completed")
expectation.expectedFulfillmentCount = 500 // 250 updates + 250 reads

let updateQueue = DispatchQueue(label: "test.updates", attributes: .concurrent)
let readQueue = DispatchQueue(label: "test.reads", attributes: .concurrent)

let evalConfig = try self.getMockUserConfig(config: "test_config_eval_reason")

// Continuously update config
for i in 0..<250 {
updateQueue.async {
testNilConfig.setUserConfig(config: i % 2 == 0 ? evalConfig : nil)
expectation.fulfill()
}
}

// Continuously read config while updates are happening
for _ in 0..<250 {
readQueue.async {
let config = testNilConfig.getUserConfig()
_ = config?.variables
_ = config?.features
expectation.fulfill()
}
}

wait(for: [expectation], timeout: 10.0)
}
}
2 changes: 1 addition & 1 deletion DevCycleTests/Models/DevCycleClientTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ class DevCycleClientTest: XCTestCase {
client.close(callback: nil)
}

func testFlushEventsWithEvalReasons() throws{
func testFlushEventsWithEvalReasons() throws {
let data = getConfigData(name: "test_config_eval_reason")
let dictionary = try JSONSerialization.jsonObject(with: data, options: .fragmentsAllowed) as! [String:Any]
let evalReasonConfig = try UserConfig(from: dictionary)
Expand Down
Loading