Skip to content

Commit

Permalink
Better concurrency support in iOS API Manager (#45)
Browse files Browse the repository at this point in the history
* Add a unit test that fails during concurrent usage

* Ensure that the unsafe operations are executed concurrently, while also ensuring completions go back to the main thread

* Add tests to the test app to show the getFeatureFlags() working concurrently and reporting to the main thread

* Remove a probable memory leak in the API manager, which was keeping two strong references to each request / response

* Convert from NSMutableData to Data class internally

* A few small formatting and commenting fixes to finish the PR

* Test for API errors in our concurrent test
  • Loading branch information
gazreese authored Mar 11, 2024
1 parent 3906854 commit 695bcdd
Show file tree
Hide file tree
Showing 15 changed files with 308 additions and 251 deletions.
16 changes: 15 additions & 1 deletion Example/FlagsmithClient/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,15 @@
import UIKit
import FlagsmithClient

func isSuccess<T,F>(_ result: Result<T,F>) -> Bool {
if case .success = result { return true } else { return false }
}

@UIApplicationMain
class AppDelegate: UIResponder, UIApplicationDelegate {

var window: UIWindow?

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

func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
// Override point for customization after application launch.
Expand Down Expand Up @@ -48,6 +52,16 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
Flagsmith.shared.hasFeatureFlag(withID: "freeze_delinquent_accounts") { (result) in
print(result)
}

// Try getting the feature flags concurrently to ensure that this does not cause any issues
// This was originally highlighted in https://github.com/Flagsmith/flagsmith-ios-client/pull/40
for _ in 1...20 {
concurrentQueue.async {
Flagsmith.shared.getFeatureFlags() { (result) in
}
}
}

//Flagsmith.shared.setTrait(Trait(key: "<my_key>", value: "<my_value>"), forIdentity: "<my_identity>") { (result) in print(result) }
//Flagsmith.shared.getIdentity("<my_key>") { (result) in print(result) }
return true
Expand Down
6 changes: 3 additions & 3 deletions Example/Podfile.lock
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
PODS:
- FlagsmithClient (1.1.2)
- FlagsmithClient (3.4.0)

DEPENDENCIES:
- FlagsmithClient (from `../`)
Expand All @@ -9,8 +9,8 @@ EXTERNAL SOURCES:
:path: "../"

SPEC CHECKSUMS:
FlagsmithClient: dd72c22b356fe008aaec4a7069e88579c3b7d979
FlagsmithClient: 0f8ed4a38dec385d73cc21a64b791b39bcc8c32b

PODFILE CHECKSUM: 9fc876dee0cf031cae843156b0740a94b4994d8c

COCOAPODS: 1.11.2
COCOAPODS: 1.13.0
6 changes: 3 additions & 3 deletions Example/Pods/Local Podspecs/FlagsmithClient.podspec.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 3 additions & 3 deletions Example/Pods/Manifest.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

414 changes: 209 additions & 205 deletions Example/Pods/Pods.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

61 changes: 35 additions & 26 deletions FlagsmithClient/Classes/Internal/APIManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,9 @@ class APIManager : NSObject, URLSessionDataDelegate {
var apiKey: String?

// store the completion handlers and accumulated data for each task
private var tasksToCompletionHandlers:[URLSessionDataTask:(Result<Data, Error>) -> Void] = [:]
private var tasksToData:[URLSessionDataTask:NSMutableData] = [:]
private var tasksToCompletionHandlers:[Int:(Result<Data, Error>) -> Void] = [:]
private var tasksToData:[Int:Data] = [:]
private let serialAccessQueue = DispatchQueue(label: "flagsmithSerialAccessQueue")

override init() {
super.init()
Expand All @@ -31,36 +32,42 @@ class APIManager : NSObject, URLSessionDataDelegate {
}

func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
if let dataTask = task as? URLSessionDataTask {
if let completion = tasksToCompletionHandlers[dataTask] {
if let error = error {
completion(.failure(FlagsmithError.unhandled(error)))
}
else {
let data = tasksToData[dataTask] ?? NSMutableData()
completion(.success(data as Data))
serialAccessQueue.sync {
if let dataTask = task as? URLSessionDataTask {
if let completion = tasksToCompletionHandlers[dataTask.taskIdentifier] {
if let error = error {
DispatchQueue.main.async { completion(.failure(FlagsmithError.unhandled(error))) }
}
else {
let data = tasksToData[dataTask.taskIdentifier] ?? Data()
DispatchQueue.main.async { completion(.success(data)) }
}
}
tasksToCompletionHandlers[dataTask.taskIdentifier] = nil
tasksToData[dataTask.taskIdentifier] = nil
}
tasksToCompletionHandlers[dataTask] = nil
tasksToData[dataTask] = nil
}
}

func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, willCacheResponse proposedResponse: CachedURLResponse, completionHandler: @escaping (CachedURLResponse?) -> Void) {

// intercept and modify the cache settings for the response
if Flagsmith.shared.cacheConfig.useCache {
let newResponse = proposedResponse.response(withExpirationDuration: Int(Flagsmith.shared.cacheConfig.cacheTTL))
completionHandler(newResponse)
} else {
completionHandler(proposedResponse)
func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, willCacheResponse proposedResponse: CachedURLResponse,
completionHandler: @escaping (CachedURLResponse?) -> Void) {
serialAccessQueue.sync {
// intercept and modify the cache settings for the response
if Flagsmith.shared.cacheConfig.useCache {
let newResponse = proposedResponse.response(withExpirationDuration: Int(Flagsmith.shared.cacheConfig.cacheTTL))
DispatchQueue.main.async { completionHandler(newResponse) }
} else {
DispatchQueue.main.async { completionHandler(proposedResponse) }
}
}
}

func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) {
let existingData = tasksToData[dataTask] ?? NSMutableData()
existingData.append(data)
tasksToData[dataTask] = existingData
serialAccessQueue.sync {
var existingData = tasksToData[dataTask.taskIdentifier] ?? Data()
existingData.append(data)
tasksToData[dataTask.taskIdentifier] = existingData
}
}

func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse, completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) {
Expand Down Expand Up @@ -98,9 +105,11 @@ class APIManager : NSObject, URLSessionDataDelegate {
}

// we must use the delegate form here, not the completion handler, to be able to modify the cache
let task = session.dataTask(with: request)
tasksToCompletionHandlers[task] = completion
task.resume()
serialAccessQueue.sync {
let task = session.dataTask(with: request)
tasksToCompletionHandlers[task.taskIdentifier] = completion
task.resume()
}
}

/// Requests a api route and only relays success or failure of the action.
Expand Down
2 changes: 1 addition & 1 deletion FlagsmithClient/Classes/Internal/Router.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import Foundation
import FoundationNetworking
#endif

enum Router {
enum Router: Sendable {
private enum HTTPMethod: String {
case get = "GET"
case post = "POST"
Expand Down
28 changes: 28 additions & 0 deletions FlagsmithClient/Tests/APIManagerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -60,4 +60,32 @@ final class APIManagerTests: FlagsmithClientTestCase {
return
}
}

func testConcurrentRequests() throws {
apiManager.apiKey = "8D5ABC87-6BBF-4AE7-BC05-4DC1AFE770DF"
let concurrentQueue = DispatchQueue(label: "concurrentQueue", attributes: .concurrent)

var expectations:[XCTestExpectation] = [];
let iterations = 1000
var error: FlagsmithError?

for concurrentIteration in 1...iterations {
let expectation = XCTestExpectation(description: "Multiple threads can access the APIManager \(concurrentIteration)")
expectations.append(expectation)
concurrentQueue.async {
self.apiManager.request(.getFlags) { (result: Result<Void, Error>) in
if case let .failure(e) = result {
error = e as? FlagsmithError
}
expectation.fulfill()
}
}
}

wait(for: expectations, timeout: 5)
// Ensure that we didn't have any errors during the process
XCTAssertTrue(error == nil)

print("Finished!")
}
}

0 comments on commit 695bcdd

Please sign in to comment.