Skip to content

Commit f95faa9

Browse files
authored
Merge pull request #134 from vazarkevych/fixing-sticky-bucketing
FeatureViewModel data racing fix
2 parents e6de06f + 9fabe9c commit f95faa9

File tree

2 files changed

+157
-123
lines changed

2 files changed

+157
-123
lines changed

GrowthBookTests/FeaturesViewModelTests.swift

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ class FeaturesViewModelTests: XCTestCase, FeaturesFlowDelegate {
2222
isSuccess = false
2323
isError = true
2424

25-
let viewModel = FeaturesViewModel(delegate: self, dataSource: FeaturesDataSource(dispatcher: MockNetworkClient(successResponse: MockResponse().successResponse, error: nil)), cachingManager: cachingManager)
25+
let viewModel = FeaturesViewModel(delegate: self, dataSource: FeaturesDataSource(dispatcher: MockNetworkClient(successResponse: MockResponse().successResponse, error: nil)), cachingManager: cachingManager, forceSynchronousSave: true)
2626

2727
viewModel.fetchFeatures(apiUrl: "")
2828

@@ -35,7 +35,7 @@ class FeaturesViewModelTests: XCTestCase, FeaturesFlowDelegate {
3535
isSuccess = false
3636
isError = true
3737

38-
let viewModel = FeaturesViewModel(delegate: self, dataSource: FeaturesDataSource(dispatcher: MockNetworkClient(successResponse: MockResponse().successResponseEncryptedFeatures, error: nil)), cachingManager: cachingManager)
38+
let viewModel = FeaturesViewModel(delegate: self, dataSource: FeaturesDataSource(dispatcher: MockNetworkClient(successResponse: MockResponse().successResponseEncryptedFeatures, error: nil)), cachingManager: cachingManager, forceSynchronousSave: true)
3939

4040
viewModel.encryptionKey = "3tfeoyW0wlo47bDnbWDkxg=="
4141
viewModel.fetchFeatures(apiUrl: "")
@@ -48,7 +48,7 @@ class FeaturesViewModelTests: XCTestCase, FeaturesFlowDelegate {
4848
isSuccess = false
4949
isError = true
5050

51-
let viewModel = FeaturesViewModel(delegate: self, dataSource: FeaturesDataSource(dispatcher: MockNetworkClient(successResponse: MockResponse().successResponse, error: nil)), cachingManager: cachingManager)
51+
let viewModel = FeaturesViewModel(delegate: self, dataSource: FeaturesDataSource(dispatcher: MockNetworkClient(successResponse: MockResponse().successResponse, error: nil)), cachingManager: cachingManager, forceSynchronousSave: true)
5252

5353
viewModel.fetchFeatures(apiUrl: "")
5454

@@ -83,7 +83,7 @@ class FeaturesViewModelTests: XCTestCase, FeaturesFlowDelegate {
8383
isSuccess = false
8484
isError = true
8585

86-
let viewModel = FeaturesViewModel(delegate: self, dataSource: FeaturesDataSource(dispatcher: MockNetworkClient(successResponse: MockResponse().successResponseEncryptedFeatures, error: nil)), cachingManager: cachingManager)
86+
let viewModel = FeaturesViewModel(delegate: self, dataSource: FeaturesDataSource(dispatcher: MockNetworkClient(successResponse: MockResponse().successResponseEncryptedFeatures, error: nil)), cachingManager: cachingManager, forceSynchronousSave: true)
8787

8888
let encryptionKey = "3tfeoyW0wlo47bDnbWDkxg=="
8989
viewModel.encryptionKey = encryptionKey
@@ -110,7 +110,7 @@ class FeaturesViewModelTests: XCTestCase, FeaturesFlowDelegate {
110110
func testError() throws {
111111
isSuccess = false
112112
isError = true
113-
let viewModel = FeaturesViewModel(delegate: self, dataSource: FeaturesDataSource(dispatcher: MockNetworkClient(successResponse: nil, error: .failedToLoadData)), cachingManager: cachingManager)
113+
let viewModel = FeaturesViewModel(delegate: self, dataSource: FeaturesDataSource(dispatcher: MockNetworkClient(successResponse: nil, error: .failedToLoadData)), cachingManager: cachingManager, forceSynchronousSave: true)
114114

115115
viewModel.fetchFeatures(apiUrl: "")
116116

@@ -122,7 +122,7 @@ class FeaturesViewModelTests: XCTestCase, FeaturesFlowDelegate {
122122
func testInvalid() throws {
123123
isSuccess = false
124124
isError = true
125-
let viewModel = FeaturesViewModel(delegate: self, dataSource: FeaturesDataSource(dispatcher: MockNetworkClient(successResponse: MockResponse().errorResponse, error: nil)), cachingManager: cachingManager)
125+
let viewModel = FeaturesViewModel(delegate: self, dataSource: FeaturesDataSource(dispatcher: MockNetworkClient(successResponse: MockResponse().errorResponse, error: nil)), cachingManager: cachingManager, forceSynchronousSave: true)
126126
viewModel.fetchFeatures(apiUrl: "")
127127

128128
XCTAssertFalse(isSuccess)

Sources/CommonMain/Features/FeaturesViewModel.swift

Lines changed: 151 additions & 117 deletions
Original file line numberDiff line numberDiff line change
@@ -12,20 +12,27 @@ protocol FeaturesFlowDelegate: AnyObject {
1212
/// View Model for Features
1313
class FeaturesViewModel {
1414
weak var delegate: FeaturesFlowDelegate?
15-
let dataSource: FeaturesDataSource
1615
var encryptionKey: String?
17-
/// Caching Manager
18-
let manager: CachingLayer
19-
/// SSE Handler for background sync
20-
internal var sseHandler: SSEHandler?
16+
17+
private let dataSource: FeaturesDataSource
18+
private let manager: CachingLayer
19+
private var sseHandler: SSEHandler?
20+
private let fileSaveQueue = DispatchQueue(label: "com.sdk.fileSaveQueue", qos: .utility)
21+
22+
var forceSynchronousSave: Bool
2123

22-
init(delegate: FeaturesFlowDelegate, dataSource: FeaturesDataSource, cachingManager: CachingLayer) {
24+
init(delegate: FeaturesFlowDelegate, dataSource: FeaturesDataSource, cachingManager: CachingLayer, forceSynchronousSave: Bool = false) {
2325
self.delegate = delegate
2426
self.dataSource = dataSource
2527
self.manager = cachingManager
28+
self.forceSynchronousSave = forceSynchronousSave
2629
self.fetchCachedFeatures()
2730
}
2831

32+
deinit {
33+
sseHandler?.disconnect()
34+
}
35+
2936
func connectBackgroundSync(sseUrl: String) {
3037
guard let url = URL(string: sseUrl) else { return }
3138

@@ -35,144 +42,171 @@ class FeaturesViewModel {
3542
let streamingUpdate = SSEHandler(url: url)
3643
sseHandler = streamingUpdate
3744

38-
streamingUpdate.addEventListener(event: "features") { [weak self] id, event, data in
39-
guard let jsonData = data?.data(using: .utf8) else { return }
40-
self?.prepareFeaturesData(data: jsonData)
45+
streamingUpdate.addEventListener(event: "features") { [weak self] _, _, data in
46+
guard let self, let jsonData = data?.data(using: .utf8) else { return }
47+
self.prepareFeaturesData(data: jsonData)
4148
}
4249
streamingUpdate.connect()
4350

4451
streamingUpdate.onDissconnect { [weak streamingUpdate] _, shouldReconnect, _ in
45-
if let shouldReconnect = shouldReconnect, shouldReconnect {
52+
if shouldReconnect == true {
4653
streamingUpdate?.connect()
4754
}
4855
}
4956
}
50-
51-
deinit {
52-
sseHandler?.disconnect()
53-
}
54-
55-
private func fetchCachedFeatures(logging: Bool = false) {
56-
// Check for cache data
57-
if let data = manager.getContent(fileName: Constants.featureCache) {
58-
let decoder = JSONDecoder()
59-
if let encryptedString = String(data: data, encoding: .utf8), let encryptionKey, !encryptionKey.isEmpty {
60-
let crypto: CryptoProtocol = Crypto()
61-
if let features = crypto.getFeaturesFromEncryptedFeatures(encryptedString: encryptedString, encryptionKey: encryptionKey) {
62-
delegate?.featuresFetchedSuccessfully(features: features, isRemote: false)
63-
} else {
64-
delegate?.featuresFetchFailed(error: .failedParsedEncryptedData, isRemote: false)
65-
if logging { logger.error("Failed get features from cached encrypted features") }
66-
}
67-
} else if let features = try? decoder.decode(Features.self, from: data) {
68-
// Call Success Delegate with mention of data available but its not remote
69-
delegate?.featuresFetchedSuccessfully(features: features, isRemote: false)
70-
} else {
71-
delegate?.featuresFetchFailed(error: .failedParsedData, isRemote: false)
72-
if logging { logger.error("Failed parse local data") }
57+
58+
func fetchFeatures(apiUrl: String?, remoteEval: Bool = false, payload: RemoteEvalParams? = nil) {
59+
fetchCachedFeatures(logging: true)
60+
61+
guard let apiUrl else {
62+
logger.error("Missing API URL")
63+
notify { $0.featuresFetchFailed(error: .failedMissingKey, isRemote: true) }
64+
return
65+
}
66+
67+
let completion: (Result<Data, Error>) -> Void = { [weak self] result in
68+
guard let self else { return }
69+
switch result {
70+
case .success(let data):
71+
self.prepareFeaturesData(data: data)
72+
case .failure(let error):
73+
logger.error("Failed to fetch features: \(error.localizedDescription)")
74+
self.notify { $0.featuresFetchFailed(error: .failedToLoadData, isRemote: true) }
7375
}
76+
}
77+
78+
if remoteEval {
79+
dataSource.fetchRemoteEval(apiUrl: apiUrl, params: payload, fetchResult: completion)
7480
} else {
75-
delegate?.featuresFetchFailed(error: .failedToLoadData, isRemote: false)
76-
if logging { logger.info("Cache directory is empty. Nothing to fetch.") }
81+
dataSource.fetchFeatures(apiUrl: apiUrl, fetchResult: completion)
7782
}
7883
}
79-
80-
/// Fetch Features
81-
func fetchFeatures(apiUrl: String?, remoteEval: Bool = false, payload: RemoteEvalParams? = nil) {
82-
// Check for cache data
83-
fetchCachedFeatures(logging: true)
8484

85-
if let apiUrl = apiUrl {
86-
if remoteEval {
87-
dataSource.fetchRemoteEval(apiUrl: apiUrl, params: payload) { result in
88-
switch result {
89-
case .success(let data):
90-
self.prepareFeaturesData(data: data)
91-
case .failure(let error):
92-
self.delegate?.featuresFetchFailed(error: .failedToLoadData, isRemote: true)
93-
logger.error("Failed get features: \(error.localizedDescription)")
94-
}
95-
}
85+
private func fetchCachedFeatures(logging: Bool = false) {
86+
guard let data = manager.getContent(fileName: Constants.featureCache) else {
87+
if logging { logger.info("Cache directory is empty. Nothing to fetch.") }
88+
notify { $0.featuresFetchFailed(error: .failedToLoadData, isRemote: false) }
89+
return
90+
}
91+
92+
let decoder = JSONDecoder()
93+
94+
if let encryptedString = String(data: data, encoding: .utf8),
95+
let encryptionKey, !encryptionKey.isEmpty {
96+
97+
let crypto: CryptoProtocol = Crypto()
98+
if let features = crypto.getFeaturesFromEncryptedFeatures(encryptedString: encryptedString, encryptionKey: encryptionKey) {
99+
notify { $0.featuresFetchedSuccessfully(features: features, isRemote: false) }
96100
} else {
97-
dataSource.fetchFeatures(apiUrl: apiUrl) { result in
98-
switch result {
99-
case .success(let data):
100-
self.prepareFeaturesData(data: data)
101-
case .failure(let error):
102-
self.delegate?.featuresFetchFailed(error: .failedToLoadData, isRemote: true)
103-
logger.error("Failed get features: \(error.localizedDescription)")
104-
}
105-
}
101+
if logging { logger.error("Failed get features from cached encrypted features") }
102+
notify { $0.featuresFetchFailed(error: .failedParsedEncryptedData, isRemote: false) }
106103
}
104+
105+
} else if let features = try? decoder.decode(Features.self, from: data) {
106+
notify { $0.featuresFetchedSuccessfully(features: features, isRemote: false) }
107107
} else {
108-
delegate?.featuresFetchFailed(error: .failedMissingKey, isRemote: true)
109-
logger.error("Failed get api URL")
108+
if logging { logger.error("Failed to parse local data") }
109+
notify { $0.featuresFetchFailed(error: .failedParsedData, isRemote: false) }
110110
}
111111
}
112-
113-
/// Cache API Response and push success event
114-
func prepareFeaturesData(data: Data) {
115-
// Call Success Delegate with mention of data available with remote
116112

113+
func prepareFeaturesData(data: Data) {
117114
let decoder = JSONDecoder()
118-
if let jsonPetitions = try? decoder.decode(FeaturesDataModel.self, from: data) {
119-
delegate?.featuresAPIModelSuccessfully(model: jsonPetitions)
120-
if let encryptedString = jsonPetitions.encryptedFeatures {
121-
if let encryptionKey = encryptionKey, !encryptionKey.isEmpty {
122-
let crypto: CryptoProtocol = Crypto()
123-
if let features = crypto.getFeaturesFromEncryptedFeatures(encryptedString: encryptedString, encryptionKey: encryptionKey) {
124-
if let featureData = encryptedString.data(using: .utf8) {
125-
manager.saveContent(fileName: Constants.featureCache, content: featureData)
126-
} else {
127-
logger.error("Failed encode features")
128-
}
129-
delegate?.featuresFetchedSuccessfully(features: features, isRemote: true)
130-
} else {
131-
delegate?.featuresFetchFailed(error: .failedEncryptedFeatures, isRemote: true)
132-
logger.error("Failed get features from encrypted features")
133-
return
134-
}
135-
} else {
136-
delegate?.featuresFetchFailed(error: .failedMissingKey, isRemote: true)
137-
logger.error("Failed get encryption key or it's empty")
138-
return
139-
}
140-
} else if let features = jsonPetitions.features {
141-
if let featureData = try? JSONEncoder().encode(features) {
142-
manager.saveContent(fileName: Constants.featureCache, content: featureData)
115+
116+
guard let jsonPetitions = try? decoder.decode(FeaturesDataModel.self, from: data) else {
117+
logger.error("Failed to decode FeaturesDataModel")
118+
notify { $0.featuresFetchFailed(error: .failedParsedData, isRemote: true) }
119+
return
120+
}
121+
122+
if let encryptedString = jsonPetitions.encryptedFeatures {
123+
handleEncryptedFeatures(encryptedString: encryptedString, jsonPetitions: jsonPetitions)
124+
} else if let features = jsonPetitions.features {
125+
handlePlainFeatures(features, jsonPetitions: jsonPetitions)
126+
} else {
127+
logger.error("Missing both encrypted and plain features")
128+
notify { $0.featuresFetchFailed(error: .failedMissingKey, isRemote: true) }
129+
}
130+
}
131+
132+
private func handleEncryptedFeatures(encryptedString: String, jsonPetitions: FeaturesDataModel) {
133+
guard let encryptionKey = encryptionKey, !encryptionKey.isEmpty else {
134+
logger.error("Missing encryption key")
135+
notify { $0.featuresFetchFailed(error: .failedMissingKey, isRemote: true) }
136+
return
137+
}
138+
139+
let crypto = Crypto()
140+
guard let features = crypto.getFeaturesFromEncryptedFeatures(encryptedString: encryptedString, encryptionKey: encryptionKey) else {
141+
logger.error("Failed to decrypt features")
142+
notify { $0.featuresFetchFailed(error: .failedEncryptedFeatures, isRemote: true) }
143+
return
144+
}
145+
146+
if let featureData = encryptedString.data(using: .utf8) {
147+
saveDataThreadSafe(fileName: Constants.featureCache, content: featureData)
148+
} else {
149+
logger.error("Failed to encode features as UTF-8")
150+
}
151+
152+
notify { $0.featuresFetchedSuccessfully(features: features, isRemote: true) }
153+
handleSavedGroups(from: jsonPetitions)
154+
}
155+
156+
private func handlePlainFeatures(_ features: Features, jsonPetitions: FeaturesDataModel) {
157+
if let featureData = try? JSONEncoder().encode(features) {
158+
saveDataThreadSafe(fileName: Constants.featureCache, content: featureData)
159+
}
160+
161+
notify { $0.featuresFetchedSuccessfully(features: features, isRemote: true) }
162+
handleSavedGroups(from: jsonPetitions)
163+
}
164+
165+
private func handleSavedGroups(from jsonPetitions: FeaturesDataModel) {
166+
if let encryptedSavedGroups = jsonPetitions.encryptedSavedGroups,
167+
!encryptedSavedGroups.isEmpty,
168+
let encryptionKey = encryptionKey,
169+
!encryptionKey.isEmpty {
170+
171+
let crypto = Crypto()
172+
if let savedGroups = crypto.getSavedGroupsFromEncryptedFeatures(encryptedString: encryptedSavedGroups, encryptionKey: encryptionKey) {
173+
if let savedGroupsData = encryptedSavedGroups.data(using: .utf8) {
174+
saveDataThreadSafe(fileName: Constants.savedGroupsCache, content: savedGroupsData)
143175
}
144-
delegate?.featuresFetchedSuccessfully(features: features, isRemote: true)
176+
notify { $0.savedGroupsFetchedSuccessfully(savedGroups: savedGroups, isRemote: true) }
145177
} else {
146-
delegate?.featuresFetchFailed(error: .failedMissingKey, isRemote: true)
147-
logger.error("Failed get encrypted features or it's empty")
148-
return
178+
logger.error("Failed to decrypt saved groups")
179+
notify { $0.savedGroupsFetchFailed(error: .failedEncryptedSavedGroups, isRemote: true) }
149180
}
150181

151-
if let encryptedSavedGroups = jsonPetitions.encryptedSavedGroups, !encryptedSavedGroups.isEmpty, let encryptionKey = encryptionKey, !encryptionKey.isEmpty {
152-
let crypto = Crypto()
153-
if let savedGroups = crypto.getSavedGroupsFromEncryptedFeatures(encryptedString: encryptedSavedGroups, encryptionKey: encryptionKey) {
154-
if let encryptedSavedGroups = encryptedSavedGroups.data(using: .utf8) {
155-
manager.saveContent(fileName: Constants.savedGroupsCache, content: encryptedSavedGroups)
156-
} else {
157-
logger.error("Failed encode saved groups")
158-
}
159-
delegate?.savedGroupsFetchedSuccessfully(savedGroups: savedGroups, isRemote: true)
160-
} else {
161-
delegate?.savedGroupsFetchFailed(error: .failedEncryptedSavedGroups, isRemote: true)
162-
logger.error("Failed get saved groups from encrypted saved groups")
163-
return
164-
}
165-
} else if let savedGroups = jsonPetitions.savedGroups {
166-
if let savedGroupsData = try? JSONEncoder().encode(savedGroups) {
167-
manager.saveContent(fileName: Constants.savedGroupsCache, content: savedGroupsData)
168-
}
169-
delegate?.savedGroupsFetchedSuccessfully(savedGroups: savedGroups, isRemote: true)
182+
} else if let savedGroups = jsonPetitions.savedGroups {
183+
if let savedGroupsData = try? JSONEncoder().encode(savedGroups) {
184+
saveDataThreadSafe(fileName: Constants.savedGroupsCache, content: savedGroupsData)
170185
}
171-
} else {
172-
delegate?.featuresFetchFailed(error: .failedParsedData, isRemote: true)
173-
logger.error("Failed get features data model")
186+
notify { $0.savedGroupsFetchedSuccessfully(savedGroups: savedGroups, isRemote: true) }
187+
}
188+
}
189+
190+
private func saveDataThreadSafe(fileName: String, content: Data) {
191+
if forceSynchronousSave {
192+
manager.saveContent(fileName: fileName, content: content)
174193
return
194+
} else {
195+
fileSaveQueue.async { [weak self] in
196+
self?.manager.saveContent(fileName: fileName, content: content)
197+
}
175198
}
176199
}
177200

201+
private func notify(_ action: @escaping (FeaturesFlowDelegate) -> Void) {
202+
if Thread.isMainThread {
203+
guard let delegate = self.delegate else { return }
204+
action(delegate)
205+
} else {
206+
DispatchQueue.main.async { [weak self] in
207+
guard let self, let delegate = self.delegate else { return }
208+
action(delegate)
209+
}
210+
}
211+
}
178212
}

0 commit comments

Comments
 (0)