diff --git a/.swiftlint.yml b/.swiftlint.yml index 9f4087bd..1194ed81 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -4,7 +4,7 @@ excluded: - ${PWD}/DerivedData - ${PWD}/.build - ${PWD}/Tools/*/.build - - ${PWD}/Sources/ConfidenceProvider/FlagResolver/ + - ${PWD}/ConfidenceDemoApp disabled_rules: - discarded_notification_center_observer @@ -19,8 +19,8 @@ analyzer_rules: - unused_import opt_in_rules: - - array_init - attributes + - array_init - closure_end_indentation - closure_spacing - collection_alignment diff --git a/ConfidenceDemoApp/ConfidenceDemoApp.xcodeproj/project.pbxproj b/ConfidenceDemoApp/ConfidenceDemoApp.xcodeproj/project.pbxproj index cb0c65d5..c9d98176 100644 --- a/ConfidenceDemoApp/ConfidenceDemoApp.xcodeproj/project.pbxproj +++ b/ConfidenceDemoApp/ConfidenceDemoApp.xcodeproj/project.pbxproj @@ -8,6 +8,7 @@ /* Begin PBXBuildFile section */ 733219BF2BE3C11100747AC2 /* ConfidenceOpenFeature in Frameworks */ = {isa = PBXBuildFile; productRef = 733219BE2BE3C11100747AC2 /* ConfidenceOpenFeature */; }; + 735EADF52CF9B64E007BC42C /* LoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 735EADF42CF9B64E007BC42C /* LoginView.swift */; }; C770C99A2A739FBC00C2AC8C /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = C770C9962A739FBC00C2AC8C /* Preview Assets.xcassets */; }; C770C99B2A739FBC00C2AC8C /* ConfidenceDemoApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = C770C9972A739FBC00C2AC8C /* ConfidenceDemoApp.swift */; }; C770C99C2A739FBC00C2AC8C /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C770C9982A739FBC00C2AC8C /* ContentView.swift */; }; @@ -35,6 +36,7 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ + 735EADF42CF9B64E007BC42C /* LoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginView.swift; sourceTree = ""; }; C770C9682A739FA000C2AC8C /* ConfidenceDemoApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = ConfidenceDemoApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; C770C9782A739FA100C2AC8C /* ConfidenceDemoAppTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ConfidenceDemoAppTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; C770C9822A739FA100C2AC8C /* ConfidenceDemoAppUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ConfidenceDemoAppUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -103,6 +105,7 @@ C770C9AA2A73A06000C2AC8C /* Info.plist */, C770C9992A739FBC00C2AC8C /* Assets.xcassets */, C770C9972A739FBC00C2AC8C /* ConfidenceDemoApp.swift */, + 735EADF42CF9B64E007BC42C /* LoginView.swift */, C770C9982A739FBC00C2AC8C /* ContentView.swift */, C770C9952A739FBC00C2AC8C /* Preview Content */, ); @@ -243,6 +246,8 @@ Base, ); mainGroup = C770C95F2A739FA000C2AC8C; + packageReferences = ( + ); productRefGroup = C770C9692A739FA000C2AC8C /* Products */; projectDirPath = ""; projectRoot = ""; @@ -285,6 +290,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 735EADF52CF9B64E007BC42C /* LoginView.swift in Sources */, C770C99C2A739FBC00C2AC8C /* ContentView.swift in Sources */, C770C99B2A739FBC00C2AC8C /* ConfidenceDemoApp.swift in Sources */, ); @@ -454,6 +460,7 @@ INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -485,6 +492,7 @@ INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", diff --git a/ConfidenceDemoApp/ConfidenceDemoApp/Assets.xcassets/AppIcon.appiconset/ConfidenceLogo.png b/ConfidenceDemoApp/ConfidenceDemoApp/Assets.xcassets/AppIcon.appiconset/ConfidenceLogo.png new file mode 100644 index 00000000..163ed5ab Binary files /dev/null and b/ConfidenceDemoApp/ConfidenceDemoApp/Assets.xcassets/AppIcon.appiconset/ConfidenceLogo.png differ diff --git a/ConfidenceDemoApp/ConfidenceDemoApp/Assets.xcassets/AppIcon.appiconset/Contents.json b/ConfidenceDemoApp/ConfidenceDemoApp/Assets.xcassets/AppIcon.appiconset/Contents.json index 13613e3e..1e8c8180 100644 --- a/ConfidenceDemoApp/ConfidenceDemoApp/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/ConfidenceDemoApp/ConfidenceDemoApp/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,6 +1,7 @@ { "images" : [ { + "filename" : "ConfidenceLogo.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" diff --git a/ConfidenceDemoApp/ConfidenceDemoApp/ConfidenceDemoApp.swift b/ConfidenceDemoApp/ConfidenceDemoApp/ConfidenceDemoApp.swift index 6d79beb7..14ecf95d 100644 --- a/ConfidenceDemoApp/ConfidenceDemoApp/ConfidenceDemoApp.swift +++ b/ConfidenceDemoApp/ConfidenceDemoApp/ConfidenceDemoApp.swift @@ -1,50 +1,91 @@ import Confidence import SwiftUI -class Status: ObservableObject { - enum State { - case unknown - case ready - case error(Error?) - } +@main +struct ConfidenceDemoApp: App { + @AppStorage("loggedUser") + private var loggedUser: String? + @AppStorage("appVersion") + private var appVersion = 0 - @Published var state: State = .unknown -} + private let confidence: Confidence + private let flaggingState = ExperimentationFlags() + private let secret = ProcessInfo.processInfo.environment["CLIENT_SECRET"] ?? "" + init() { + @AppStorage("appVersion") var appVersion = 0 + @AppStorage("loggedUser") var loggedUser: String? + appVersion += 1 // Simulate update of the app on every new run + var context = ["app_version": ConfidenceValue.init(integer: appVersion)] + if let user = loggedUser { + context["user_id"] = ConfidenceValue.init(string: user) + } -@main -struct ConfidenceDemoApp: App { - @StateObject private var lifecycleObserver = ConfidenceAppLifecycleProducer() + confidence = Confidence + .Builder(clientSecret: secret, loggerLevel: .TRACE) + .withContext(initialContext: context) + .build() + do { + // NOTE: here we are activating all the flag values from storage, regardless of how `context` looks now + try confidence.activate() + } catch { + flaggingState.state = .error(ExperimentationFlags.CustomError(message: error.localizedDescription)) + } + // flaggingState.color is set here at startup and remains immutable until a user logs out + let eval = confidence.getEvaluation( + key: "swift-demoapp.color", + defaultValue: "Gray") + flaggingState.color = ContentView.getColor( + color: eval.value) + flaggingState.reason = eval.reason + + self.appVersion = appVersion + self.loggedUser = loggedUser + updateConfidence() + } var body: some Scene { WindowGroup { - let secret = ProcessInfo.processInfo.environment["CLIENT_SECRET"] ?? "" - let confidence = Confidence.Builder(clientSecret: secret, loggerLevel: .TRACE) - .withContext(initialContext: [ - "targeting_key": ConfidenceValue(string: UUID.init().uuidString), - "user_id": .init(string: "user2") - ]) - .build() - - let status = Status() - - ContentView(confidence: confidence, status: status) - .task { - do { - confidence.track(producer: lifecycleObserver) - try await self.setup(confidence: confidence) - status.state = .ready - } catch { - status.state = .error(error) - print(error.localizedDescription) - } - } + if loggedUser == nil { + LoginView(confidence: confidence) + .environmentObject(flaggingState) + } else { + ContentView(confidence: confidence) + .environmentObject(flaggingState) + } + } + } + + private func updateConfidence() { + Task { + do { + flaggingState.state = .loading + try await Task.sleep(nanoseconds: 2 * 1_000_000_000) // simulating slow network + // The flags in storage are refreshed for the current `context`, and activated + // After this line, fresh (and potentially new) flags values can be accessed + try await confidence.fetchAndActivate() + flaggingState.state = .ready + } catch { + flaggingState.state = .error(ExperimentationFlags.CustomError(message: error.localizedDescription)) + } } } } -extension ConfidenceDemoApp { - func setup(confidence: Confidence) async throws { - try await confidence.fetchAndActivate() +class ExperimentationFlags: ObservableObject { + var color: Color = .red // This is set on applicaaton start, and reset on user logout + var reason: ResolveReason = .unknown + @Published var state: State = .notReady + + enum State: Equatable { + case unknown + case notReady + case loading + case ready + case error(CustomError?) + } + + public struct CustomError: Error, Equatable { + let message: String } } diff --git a/ConfidenceDemoApp/ConfidenceDemoApp/ContentView.swift b/ConfidenceDemoApp/ConfidenceDemoApp/ContentView.swift index cfa931f8..8ea0de8a 100644 --- a/ConfidenceDemoApp/ConfidenceDemoApp/ContentView.swift +++ b/ConfidenceDemoApp/ConfidenceDemoApp/ContentView.swift @@ -3,59 +3,145 @@ import Confidence import Combine struct ContentView: View { - @ObservedObject var status: Status - @StateObject var text = DisplayText() - @StateObject var color = FlagColor() + @EnvironmentObject + var flaggingState: ExperimentationFlags + @AppStorage("loggedUser") + private var loggedUser: String? + @State + private var isLoggingOut = false + @State + private var loggedOut = false private let confidence: Confidence - init(confidence: Confidence, status: Status) { + init(confidence: Confidence, color: Color? = nil) { self.confidence = confidence - self.status = status } var body: some View { - if case .ready = status.state { + NavigationStack { VStack { - Image(systemName: "flag") - .imageScale(.large) - .foregroundColor(color.color) - .padding(10) - Text(text.text) - Button("Get remote flag value") { - text.text = confidence.getValue(key: "swift-demoapp.color", defaultValue: "ERROR") - if text.text == "Green" { - color.color = .green - } else if text.text == "Yellow" { - color.color = .yellow - } else { - color.color = .red - } + if let user = loggedUser { + Text("Hello \(user)") + .font(.largeTitle) + .padding() + } + Spacer() + NavigationLink(destination: AboutPage(confidence: confidence)) { + Text("Navigate") + .font(.headline) + .foregroundColor(.white) + .padding(.horizontal, 20) + .padding(.vertical, 10) + .background(Color.blue) + .clipShape(Capsule()) } - Button("Flush 🚽") { - confidence.flush() + .padding() + Button(action: { + isLoggingOut = true + loggedUser = nil + flaggingState.state = .loading + flaggingState.color = .gray + Task { + await confidence.removeContextAndWait(key: "user_id") + flaggingState.state = .ready + } + loggedOut = true + }, label: { + Text("Logout") + .font(.headline) + .foregroundColor(.white) + .padding(.horizontal, 20) + .padding(.vertical, 10) + .background(Color.red) + .clipShape(Capsule()) + }) + .navigationDestination(isPresented: $loggedOut) { + LoginView(confidence: confidence) } + Spacer() } + Spacer() + HStack { + Text("[1]") + if flaggingState.state == .loading && !isLoggingOut { + Text("Loading the text color...") + .font(.body) + } else { + let eval = confidence.getEvaluation(key: "swift-demoapp.color", defaultValue: "Gray") + Text("This text only appears after a successful flag fetching") + .font(.body) + .foregroundStyle(ContentView.getColor(color: eval.value)) + Spacer() + Text("[\(eval.reason)]") + } + }.frame(maxWidth: .infinity, alignment: .leading) .padding() - } else if case .error(let error) = status.state { - VStack { - Text("Provider Error") - Text(error?.localizedDescription ?? "An unknow error has occured.") - .foregroundColor(.red) - } - } else { - VStack { - ProgressView() - } + HStack { + let eval = confidence.getEvaluation( + key: "swift-demoapp.color", + defaultValue: "Gray") + Text("[2]") + Text("This text color dynamically changes on each flags fetch") + .font(.body) + .foregroundStyle(ContentView.getColor( + color: eval.value)) + Spacer() + Text("[\(eval.reason)]") + }.frame(maxWidth: .infinity, alignment: .leading) + .padding() + + HStack { + Text("[3]") + Text("This text color is fixed from app start, doesn't react on flag fetches") + .font(.body) + .foregroundStyle(flaggingState.color) + Spacer() + Text("[\(flaggingState.reason)]") + }.frame(maxWidth: .infinity, alignment: .leading) + .padding() } } -} -class DisplayText: ObservableObject { - @Published var text = "Hello World!" + static func getColor(color: String) -> Color { + switch color { + case "Green": + return .green + case "Yellow": + return .yellow + case "Gray": + return .gray + default: + return .red + } + } } +struct AboutPage: View { + @State + private var textColor = Color.red + @State + private var reason = ResolveReason.unknown + private let confidence: Confidence + + init(confidence: Confidence) { + self.confidence = confidence + } -class FlagColor: ObservableObject { - @Published var color: Color = .black + var body: some View { + HStack { + Text("This text color is set on onAppear, doesn't wait for flag fetch") + .font(.body) + .foregroundStyle(textColor) + .padding() + .onAppear { + let eval = confidence.getEvaluation(key: "swift-demoapp.color", defaultValue: "Gray") + textColor = ContentView.getColor( + color: eval.value) + reason = eval.reason + } + Spacer() + Text("[\(reason)]") + } + } } diff --git a/ConfidenceDemoApp/ConfidenceDemoApp/LoginView.swift b/ConfidenceDemoApp/ConfidenceDemoApp/LoginView.swift new file mode 100644 index 00000000..07a69d92 --- /dev/null +++ b/ConfidenceDemoApp/ConfidenceDemoApp/LoginView.swift @@ -0,0 +1,80 @@ +import SwiftUI +import Confidence + +struct LoginView: View { + @EnvironmentObject + var flaggingState: ExperimentationFlags + @AppStorage("loggedUser") + private var loggedUser: String? + @State + private var loginCompleted = false + @State + private var flagsLoaded = false + @State + private var loggingIn = false + + private let confidence: Confidence + + init(confidence: Confidence) { + self.confidence = confidence + } + + var body: some View { + NavigationStack { + VStack { + Spacer() + ZStack { + Button(action: { + do { + try confidence.activate() + } catch { + flaggingState.state = .error( + ExperimentationFlags.CustomError(message: error.localizedDescription)) + } + + let eval = confidence.getEvaluation(key: "swift-demoapp.color", defaultValue: "Gray") + flaggingState.color = ContentView.getColor( + color: eval.value + ) + flaggingState.reason = eval.reason + + // Simulating a module that handles feature flagging state during login + Task { + flaggingState.state = .loading + try? await Task.sleep(nanoseconds: 5 * 1_000_000_000) // simulating network delay + // putContext adds the user_id field to the evaluation context and fetches values for it + await confidence.putContextAndWait(context: ["user_id": .init(string: "user1")]) + flaggingState.state = .ready + } + + // Simulating a module that handles the actual login mechanism for a user + Task { + loggingIn = true + try? await Task.sleep(nanoseconds: 1 * 1_000_000_000) // simulating network delay + loggedUser = "user1" + loggingIn = false + loginCompleted = true + } + }, label: { + Text("Login as user1") + .font(.headline) + .foregroundColor(.white) + .padding(.horizontal, 20) + .padding(.vertical, 10) + .background(Color.blue) + .clipShape(Capsule()) + }) + .navigationDestination(isPresented: $loginCompleted) { + ContentView(confidence: confidence) + } + + if loggingIn { + ProgressView() + .offset(y: 40) + } + } + Spacer() + } + } + } +} diff --git a/README.md b/README.md index e45e2e9c..a2cde3a6 100644 --- a/README.md +++ b/README.md @@ -50,38 +50,57 @@ If your app is using some of the features of Swift 6, we recommend setting the * ```swift import Confidence -let confidence = Confidence.Builder(clientSecret: "mysecret", loggerLevel: .NONE).build() +let confidence = Confidence + .Builder(clientSecret: "mysecret", loggerLevel: .NONE) + .withContext(context: ["user_id": ConfidenceValue(string: "user_1")]) + .build() await confidence.fetchAndActivate() ``` - The `clientSecret` for your application can be generated in the Confidence portal. - The `loggerLevel` sets the verbosity level for logging to console. This can be useful while testing your integration with the Confidence SDK. +- `withContext()` sets the initial context. The context is a key-value map used for sampling and for targeting, so it determines how flags are evaluated by the Confidence backend. _Note: the Confidence SDK has been intended to work as a single instance in your Application. Creating multiple instances in the same runtime could lead to unexpected behaviours._ ### Initialization strategy -`confidence.activateAndFetch()` is an async function that fetches the flags from the Confidence backend, -stores the result on disk, and make the same data ready for the Application to be consumed. +After creating the confidence instance, you can choose between different strategies to initialize the SDK: +- `await confidence.fetchAndActivate()`: async function that fetches the flags from the Confidence backend according to the current context, +stores the result in storage, and make the same data ready for the Application to be consumed. -The alternative option is to call `confidence.activate()`: this loads previously fetched flags data +- `confidence.activate()`: this loads fetched flags data from storage and makes that available for the Application to consume right away. -To avoid waiting on backend calls when the Application starts, the suggested approach is to call -`confidence.activate()` and then trigger a background refresh via `confidence.asyncFetch()` for future sessions. -### Setting the context -The context is a key-value map used for sampling and for targeting, when flag are evaluated by the Confidence backend. -It is also appended to the tracked events, making it a great way to create dimensions for metrics in Confidence. +If you wish to avoid waiting on backend calls when the Application starts, the suggested approach is to call +`confidence.activate()` and then call `confidence.asyncFetch()` to update the flag values in storage to be used on a future `activate()`. + +**Important:** `confidence.activate()` ignores the current context: even if the current context has changed since the last fetch, flag values from the last fetch will be exposed to the Application. + +### Managing the context +The context is set when instantiating the Confidence instance, but it can be updated at runtime: ```swift -confidence.putContext(context: ["key": ConfidenceValue(string: "value")]) +await confidence.putContext(context: ["key": ConfidenceValue(string: "value")]) +await confidence.putContext(key: "key", value: ConfidenceValue(string: "value")) +await confidence.removeContext(key: "key") ``` -Note that a `ConfidenceValue` is accepted a map values, which has a constructor for all the value types -supported by Confidence. +These functions are async functions, because the flag values are fetched from the backend for the new context, put in storage and then exposed to the Application. + +_Note: Changing the context could cause a change in the flag values._ + +_Note: When a context change is performed and the SDK is fetching the new values for it, the old values are still available for the Application to consume but marked with evaluation reason `STALE`._ + +When integrating the SDK in your Application, it's important to understand the implications of changing the context at runtime: +- You might want to keep the flag values unchanged within a certain session +- You might want to show a loading UI while re-fetching all flag values +- You might want the UI to dynamically adapt to underlying changes in flag values + +You can find examples on how to implement these different scenarios in the Demo Application project within this repo. -### Resolving feature flags +### Read flag values Once the Confidence instance is **activated**, you can access the flag values using the `getValue` method or the `getEvaluation` functions. Both functions use generics to return a type defined by the default value type. diff --git a/Sources/Confidence/Confidence.swift b/Sources/Confidence/Confidence.swift index 05127b70..c94cd461 100644 --- a/Sources/Confidence/Confidence.swift +++ b/Sources/Confidence/Confidence.swift @@ -3,25 +3,30 @@ import Foundation import Combine import os +// swiftlint:disable:next type_body_length public class Confidence: ConfidenceEventSender { + // User configurations private let clientSecret: String - private var region: ConfidenceRegion - private let parent: ConfidenceContextProvider? + private let region: ConfidenceRegion + private let debugLogger: DebugLogger? + + // Resources related to managing context and flags + private let parentContextProvider: ConfidenceContextProvider? + private let contextManager: ContextManager + private var cache = FlagResolution.EMPTY + + // Core components managing internal SDK functionality private let eventSenderEngine: EventSenderEngine - private let contextSubject = CurrentValueSubject([:]) - private var removedContextKeys: Set = Set() - private let contextSubjectQueue = DispatchQueue(label: "com.confidence.queue.contextsubject") - private let cacheQueue = DispatchQueue(label: "com.confidence.queue.cache") + private let storage: Storage private let flagApplier: FlagApplier - private var cache = FlagResolution.EMPTY - private var storage: Storage + + // Synchronization and task management resources private var cancellables = Set() - private var currentFetchTask: Task<(), Never>? - private let debugLogger: DebugLogger? + private let cacheQueue = DispatchQueue(label: "com.confidence.queue.cache") + private var taskManager = TaskManager() // Internal for testing internal let remoteFlagResolver: ConfidenceResolveClient - internal let contextReconciliatedChanges = PassthroughSubject() public static let sdkId: String = "SDK_ID_SWIFT_CONFIDENCE" @@ -41,35 +46,14 @@ public class Confidence: ConfidenceEventSender { self.clientSecret = clientSecret self.region = region self.storage = storage - self.contextSubject.value = context - self.parent = parent - self.storage = storage + self.contextManager = ContextManager(initialContext: context) + self.parentContextProvider = parent self.flagApplier = flagApplier self.remoteFlagResolver = remoteFlagResolver self.debugLogger = debugLogger if let visitorId { - putContext(context: ["visitor_id": ConfidenceValue.init(string: visitorId)]) - } - - contextChanges().sink { [weak self] context in - guard let self = self else { - return - } - self.currentFetchTask?.cancel() - self.currentFetchTask = Task { - do { - let context = self.getContext() - try await self.fetchAndActivate() - self.contextReconciliatedChanges.send(context.hash()) - } catch { - debugLogger?.logMessage( - message: "\(error)", - isWarning: true - ) - } - } + putContextLocal(context: ["visitor_id": ConfidenceValue.init(string: visitorId)]) } - .store(in: &cancellables) } /** @@ -83,7 +67,6 @@ public class Confidence: ConfidenceEventSender { } let savedFlags = try storage.load(defaultValue: FlagResolution.EMPTY) cache = savedFlags - debugLogger?.logFlags(action: "Activate", flag: "") } } @@ -94,14 +77,7 @@ public class Confidence: ConfidenceEventSender { Fetching is best-effort, so no error is propagated. Errors can still be thrown if something goes wrong access data on disk. */ public func fetchAndActivate() async throws { - do { - try await internalFetch() - } catch { - debugLogger?.logMessage( - message: "\(error)", - isWarning: true - ) - } + await asyncFetch() try activate() } @@ -109,20 +85,18 @@ public class Confidence: ConfidenceEventSender { Fetch latest flag evaluations and store them on disk. Note that "activate" must be called for this data to be made available in the app session. */ - public func asyncFetch() { - Task { - do { - try await internalFetch() - } catch { - debugLogger?.logMessage( - message: "\(error )", - isWarning: true - ) - } + public func asyncFetch() async { + do { + try await internalFetch() + } catch { + debugLogger?.logMessage( + message: "\(error )", + isWarning: true + ) } } - func internalFetch() async throws { + private func internalFetch() async throws { let context = getContext() let resolvedFlags = try await remoteFlagResolver.resolve(ctx: context) let resolution = FlagResolution( @@ -130,10 +104,16 @@ public class Confidence: ConfidenceEventSender { flags: resolvedFlags.resolvedValues, resolveToken: resolvedFlags.resolveToken ?? "" ) - debugLogger?.logFlags(action: "Fetch", flag: "") try storage.save(data: resolution) } + /** + Returns true if any flag is found in storage. + */ + public func isStorageEmpty() -> Bool { + return storage.isEmpty() + } + /** Get evaluation data for a specific flag. Evaluation data includes the variant's name and reason/error information. - Parameter key:expects dot-notation to retrieve a specific entry in the flag's value, e.g. "flagname.myentry" @@ -169,25 +149,139 @@ public class Confidence: ConfidenceEventSender { return getEvaluation(key: key, defaultValue: defaultValue).value } - func isStorageEmpty() -> Bool { - return storage.isEmpty() + public func getContext() -> ConfidenceStruct { + let parentContext = parentContextProvider?.getContext() ?? [:] + return contextManager.getContext(parentContext: parentContext) + } + + public func putContextAndWait(key: String, value: ConfidenceValue) async { + taskManager.currentTask = Task { + let newContext = contextManager.updateContext(withValues: [key: value], removedKeys: []) + do { + try await self.fetchAndActivate() + debugLogger?.logContext(action: "PutContext", context: newContext) + } catch { + debugLogger?.logMessage(message: "Error when putting context: \(error)", isWarning: true) + } + } + await awaitReconciliation() + } + + public func putContextAndWait(context: ConfidenceStruct, removedKeys: [String] = []) async { + taskManager.currentTask = Task { + let newContext = contextManager.updateContext(withValues: context, removedKeys: removedKeys) + do { + try await self.fetchAndActivate() + debugLogger?.logContext(action: "PutContext", context: newContext) + } catch { + debugLogger?.logMessage(message: "Error when putting context: \(error)", isWarning: true) + } + } + await awaitReconciliation() + } + + public func putContextAndWait(context: ConfidenceStruct) async { + taskManager.currentTask = Task { + let newContext = contextManager.updateContext(withValues: context, removedKeys: []) + do { + try await fetchAndActivate() + debugLogger?.logContext( + action: "PutContext", + context: newContext) + } catch { + debugLogger?.logMessage( + message: "Error when putting context: \(error)", + isWarning: true) + } + } + await awaitReconciliation() + } + + public func removeContextAndWait(key: String) async { + taskManager.currentTask = Task { + let newContext = contextManager.updateContext(withValues: [:], removedKeys: [key]) + do { + try await self.fetchAndActivate() + debugLogger?.logContext( + action: "RemoveContext", + context: newContext) + } catch { + debugLogger?.logMessage( + message: "Error when removing context key: \(error)", + isWarning: true) + } + } + await awaitReconciliation() } /** - Listen to changes in the context that is local to this Confidence instance. + Adds/override entry to local context data. Does not trigger fetchAndActivate after the context change. */ - public func contextChanges() -> AnyPublisher { - return contextSubject - .dropFirst() - .removeDuplicates() - .eraseToAnyPublisher() + public func putContextLocal(context: ConfidenceStruct, removeKeys removedKeys: [String] = []) { + let newContext = contextManager.updateContext(withValues: context, removedKeys: removedKeys) + debugLogger?.logContext( + action: "PutContextLocal", + context: newContext) } - public func track(eventName: String, data: ConfidenceStruct) throws { - try eventSenderEngine.emit( - eventName: eventName, - data: data, - context: getContext() + public func putContext(key: String, value: ConfidenceValue) { + taskManager.currentTask = Task { + await putContextAndWait(key: key, value: value) + } + } + + public func putContext(context: ConfidenceStruct) { + taskManager.currentTask = Task { + await putContextAndWait(context: context) + } + } + + public func putContext(context: ConfidenceStruct, removeKeys removedKeys: [String] = []) { + taskManager.currentTask = Task { + await putContextAndWait(context: context, removedKeys: removedKeys) + } + } + + public func removeContext(key: String) { + taskManager.currentTask = Task { + await removeContextAndWait(key: key) + } + } + + public func putContext(context: ConfidenceStruct, removedKeys: [String]) { + taskManager.currentTask = Task { + let newContext = contextManager.updateContext(withValues: context, removedKeys: removedKeys) + do { + try await self.fetchAndActivate() + debugLogger?.logContext( + action: "RemoveContext", + context: newContext) + } catch { + debugLogger?.logMessage( + message: "Error when putting context: \(error)", + isWarning: true) + } + } + } + + /** + Ensures all the already-started context changes prior to this function have been reconciliated + */ + public func awaitReconciliation() async { + await taskManager.awaitReconciliation() + } + + public func withContext(_ context: ConfidenceStruct) -> ConfidenceEventSender { + return Self.init( + clientSecret: clientSecret, + region: region, + eventSenderEngine: eventSenderEngine, + flagApplier: flagApplier, + remoteFlagResolver: remoteFlagResolver, + storage: storage, + context: context, + parent: self, + debugLogger: debugLogger ) } @@ -214,94 +308,67 @@ public class Confidence: ConfidenceEventSender { if let contextProducer = producer as? ConfidenceContextProducer { contextProducer.produceContexts() .sink { [weak self] context in - guard let self = self else { - return + Task { [weak self] in + guard let self = self else { return } + await self.putContextAndWait(context: context) } - self.putContext(context: context) } .store(in: &cancellables) } } + public func track(eventName: String, data: ConfidenceStruct) throws { + try eventSenderEngine.emit( + eventName: eventName, + data: data, + context: getContext() + ) + } + public func flush() { eventSenderEngine.flush() } +} - public func getContext() -> ConfidenceStruct { - let parentContext = parent?.getContext() ?? [:] - var reconciledCtx = parentContext.filter { - !removedContextKeys.contains($0.key) - } - self.contextSubject.value.forEach { entry in - reconciledCtx.updateValue(entry.value, forKey: entry.key) - } - return reconciledCtx - } +private class ContextManager { + private var context: ConfidenceStruct = [:] + private var removedContextKeys: Set = Set() + private let contextQueue = DispatchQueue(label: "com.confidence.queue.context") - public func putContext(key: String, value: ConfidenceValue) { - withLock { confidence in - var map = confidence.contextSubject.value - map[key] = value - confidence.contextSubject.value = map - confidence.debugLogger?.logContext(action: "PutContext", context: confidence.contextSubject.value) - } + public init(initialContext: ConfidenceStruct) { + context = initialContext } - public func putContext(context: ConfidenceStruct) { - withLock { confidence in - var map = confidence.contextSubject.value - for entry in context { - map.updateValue(entry.value, forKey: entry.key) + func updateContext(withValues: ConfidenceStruct, removedKeys: [String]) -> ConfidenceStruct { + contextQueue.sync { [weak self] in + guard let self = self else { + return [:] } - confidence.contextSubject.value = map - confidence.debugLogger?.logContext(action: "PutContext", context: confidence.contextSubject.value) - } - } - - public func putContext(context: ConfidenceStruct, removeKeys removedKeys: [String] = []) { - withLock { confidence in - var map = confidence.contextSubject.value + var map = self.context for removedKey in removedKeys { map.removeValue(forKey: removedKey) + removedContextKeys.insert(removedKey) } - for entry in context { + for entry in withValues { map.updateValue(entry.value, forKey: entry.key) } - confidence.contextSubject.value = map - confidence.debugLogger?.logContext(action: "PutContext", context: confidence.contextSubject.value) + self.context = map + return self.context } } - public func removeKey(key: String) { - withLock { confidence in - var map = confidence.contextSubject.value - map.removeValue(forKey: key) - confidence.contextSubject.value = map - confidence.removedContextKeys.insert(key) - confidence.debugLogger?.logContext(action: "RemoveContext", context: confidence.contextSubject.value) - } - } - - public func withContext(_ context: ConfidenceStruct) -> ConfidenceEventSender { - return Self.init( - clientSecret: clientSecret, - region: region, - eventSenderEngine: eventSenderEngine, - flagApplier: flagApplier, - remoteFlagResolver: remoteFlagResolver, - storage: storage, - context: context, - parent: self, - debugLogger: debugLogger - ) - } - - private func withLock(callback: @escaping (Confidence) -> Void) { - contextSubjectQueue.sync { [weak self] in + func getContext(parentContext: ConfidenceStruct) -> ConfidenceStruct { + contextQueue.sync { [weak self] in guard let self = self else { - return + return [:] + } + var reconciledCtx = parentContext.filter { + !self.removedContextKeys.contains($0.key) + } + context.forEach { entry in + reconciledCtx.updateValue(entry.value, forKey: entry.key) } - callback(self) + return reconciledCtx } } } diff --git a/Sources/Confidence/ConfidenceEventSender.swift b/Sources/Confidence/ConfidenceEventSender.swift index 18e7b829..5c4d5380 100644 --- a/Sources/Confidence/ConfidenceEventSender.swift +++ b/Sources/Confidence/ConfidenceEventSender.swift @@ -20,13 +20,44 @@ public protocol ConfidenceEventSender: ConfidenceContextProvider { func flush() /** Adds/override entry to local context data + Triggers fetchAndActivate after the context change + */ + func putContextAndWait(key: String, value: ConfidenceValue) async + /** + Adds/override entry to local context data + Triggers fetchAndActivate after the context change + */ + func putContextAndWait(context: ConfidenceStruct) async + /** + Removes entry from localcontext data + It hides entries with this key from parents' data (without modifying parents' data) + Triggers fetchAndActivate after the context change + */ + func removeContextAndWait(key: String) async + /** + Combination of putContext and removeContext + */ + func putContextAndWait(context: ConfidenceStruct, removedKeys: [String]) async + /** + Adds/override entry to local context data + Triggers fetchAndActivate after the context change */ func putContext(key: String, value: ConfidenceValue) /** + Adds/override entry to local context data + Triggers fetchAndActivate after the context change + */ + func putContext(context: ConfidenceStruct) + /** Removes entry from localcontext data It hides entries with this key from parents' data (without modifying parents' data) + Triggers fetchAndActivate after the context change + */ + func removeContext(key: String) + /** + Combination of putContext and removeContext */ - func removeKey(key: String) + func putContext(context: ConfidenceStruct, removedKeys: [String]) /** Creates a child event sender instance that maintains access to its parent's data */ diff --git a/Sources/Confidence/DebugLogger.swift b/Sources/Confidence/DebugLogger.swift index 5535c1b3..1f47dcd0 100644 --- a/Sources/Confidence/DebugLogger.swift +++ b/Sources/Confidence/DebugLogger.swift @@ -5,6 +5,7 @@ internal protocol DebugLogger { func logEvent(action: String, event: ConfidenceEvent?) func logMessage(message: String, isWarning: Bool) func logFlags(action: String, flag: String) + func logFlags(action: String, context: ConfidenceStruct) func logContext(action: String, context: ConfidenceStruct) func logResolveDebugURL(flagName: String, context: ConfidenceStruct) } @@ -62,6 +63,10 @@ internal class DebugLoggerImpl: DebugLogger { log(messageLevel: .TRACE, message: "[\(action)] \(flag)") } + func logFlags(action: String, context: ConfidenceStruct) { + log(messageLevel: .TRACE, message: "[\(action)] \(context)") + } + func logContext(action: String, context: ConfidenceStruct) { log(messageLevel: .TRACE, message: "[\(action)] \(context)") } diff --git a/Sources/Confidence/TaskManager.swift b/Sources/Confidence/TaskManager.swift new file mode 100644 index 00000000..13f4724e --- /dev/null +++ b/Sources/Confidence/TaskManager.swift @@ -0,0 +1,30 @@ +import Foundation + +internal class TaskManager { + public var currentTask: Task<(), Never>? { + didSet { + if let oldTask = oldValue { + oldTask.cancel() + } + } + } + public func awaitReconciliation() async { + while let task = self.currentTask { + // If current task is cancelled, return + if task.isCancelled { + return + } + // Wait for result of current task + await task.value + // If current task gets cancelled, check again if a new task was set + if task.isCancelled { + continue + } + // If current task finished successfully + // and the set task has not changed, we are done waiting + if self.currentTask == task { + return + } + } + } +} diff --git a/Sources/ConfidenceProvider/ConfidenceFeatureProvider.swift b/Sources/ConfidenceProvider/ConfidenceFeatureProvider.swift index 538518a3..cab13418 100644 --- a/Sources/ConfidenceProvider/ConfidenceFeatureProvider.swift +++ b/Sources/ConfidenceProvider/ConfidenceFeatureProvider.swift @@ -40,16 +40,15 @@ public class ConfidenceFeatureProvider: FeatureProvider { } public func initialize(initialContext: OpenFeature.EvaluationContext?) { - self.updateConfidenceContext(context: initialContext ?? MutableContext(attributes: [:])) - if self.initializationStrategy == .activateAndFetchAsync { - eventHandler.send(.ready) - } - + let context = ConfidenceTypeMapper.from(ctx: initialContext ?? MutableContext(attributes: [:])) + confidence.putContextLocal(context: context) do { if initializationStrategy == .activateAndFetchAsync { try confidence.activate() eventHandler.send(.ready) - confidence.asyncFetch() + Task { + await confidence.asyncFetch() + } } else { Task { do { @@ -77,16 +76,13 @@ public class ConfidenceFeatureProvider: FeatureProvider { oldContext: OpenFeature.EvaluationContext?, newContext: OpenFeature.EvaluationContext ) { - var removedKeys: [String] = [] - if let oldContext = oldContext { - removedKeys = Array(oldContext.asMap().filter { key, _ in !newContext.asMap().keys.contains(key) }.keys) - } - - self.updateConfidenceContext(context: newContext, removedKeys: removedKeys) - } + let removedKeys: [String] = oldContext.map { + Array($0.asMap().filter { key, _ in !newContext.asMap().keys.contains(key) }.keys) + } ?? [] - private func updateConfidenceContext(context: EvaluationContext, removedKeys: [String] = []) { - confidence.putContext(context: ConfidenceTypeMapper.from(ctx: context), removeKeys: removedKeys) + Task { + confidence.putContext(context: ConfidenceTypeMapper.from(ctx: newContext), removedKeys: removedKeys) + } } public func getBooleanEvaluation(key: String, defaultValue: Bool, context: EvaluationContext?) throws diff --git a/Tests/ConfidenceTests/ConfidenceContextTests.swift b/Tests/ConfidenceTests/ConfidenceContextTests.swift index 155c952d..3b4a6c45 100644 --- a/Tests/ConfidenceTests/ConfidenceContextTests.swift +++ b/Tests/ConfidenceTests/ConfidenceContextTests.swift @@ -30,7 +30,7 @@ final class ConfidenceContextTests: XCTestCase { XCTAssertEqual(confidenceChild.getContext(), expected) } - func testWithContextUpdateParent() { + func testWithContextUpdateParent() async { let client = RemoteConfidenceResolveClient( options: ConfidenceClientOptions( credentials: ConfidenceClientCredentials.clientSecret(secret: ""), timeoutIntervalForRequest: 10), @@ -51,7 +51,7 @@ final class ConfidenceContextTests: XCTestCase { let confidenceChild: ConfidenceEventSender = confidenceParent.withContext( ["k2": ConfidenceValue(string: "v2")] ) - confidenceParent.putContext( + await confidenceParent.putContextAndWait( key: "k3", value: ConfidenceValue(string: "v3")) let expected = [ @@ -62,7 +62,7 @@ final class ConfidenceContextTests: XCTestCase { XCTAssertEqual(confidenceChild.getContext(), expected) } - func testUpdateLocalContext() { + func testUpdateLocalContext() async { let client = RemoteConfidenceResolveClient( options: ConfidenceClientOptions( credentials: ConfidenceClientCredentials.clientSecret(secret: ""), timeoutIntervalForRequest: 10), @@ -80,7 +80,7 @@ final class ConfidenceContextTests: XCTestCase { parent: nil, debugLogger: nil ) - confidence.putContext( + await confidence.putContextAndWait( key: "k1", value: ConfidenceValue(string: "v3")) let expected = [ @@ -89,7 +89,7 @@ final class ConfidenceContextTests: XCTestCase { XCTAssertEqual(confidence.getContext(), expected) } - func testUpdateLocalContextWithoutOverride() { + func testUpdateLocalContextWithoutOverride() async { let client = RemoteConfidenceResolveClient( options: ConfidenceClientOptions( credentials: ConfidenceClientCredentials.clientSecret(secret: ""), timeoutIntervalForRequest: 10), @@ -110,7 +110,7 @@ final class ConfidenceContextTests: XCTestCase { let confidenceChild: ConfidenceEventSender = confidenceParent.withContext( ["k2": ConfidenceValue(string: "v2")] ) - confidenceChild.putContext( + await confidenceChild.putContextAndWait( key: "k2", value: ConfidenceValue(string: "v4")) let expected = [ @@ -120,7 +120,7 @@ final class ConfidenceContextTests: XCTestCase { XCTAssertEqual(confidenceChild.getContext(), expected) } - func testUpdateParentContextWithOverride() { + func testUpdateParentContextWithOverride() async { let client = RemoteConfidenceResolveClient( options: ConfidenceClientOptions( credentials: ConfidenceClientCredentials.clientSecret(secret: ""), timeoutIntervalForRequest: 10), @@ -141,7 +141,7 @@ final class ConfidenceContextTests: XCTestCase { let confidenceChild: ConfidenceEventSender = confidenceParent.withContext( ["k2": ConfidenceValue(string: "v2")] ) - confidenceParent.putContext( + await confidenceParent.putContextAndWait( key: "k2", value: ConfidenceValue(string: "v4")) let expected = [ @@ -151,7 +151,7 @@ final class ConfidenceContextTests: XCTestCase { XCTAssertEqual(confidenceChild.getContext(), expected) } - func testRemoveContextEntry() { + func testRemoveContextEntry() async { let client = RemoteConfidenceResolveClient( options: ConfidenceClientOptions( credentials: ConfidenceClientCredentials.clientSecret(secret: ""), timeoutIntervalForRequest: 10), @@ -169,14 +169,14 @@ final class ConfidenceContextTests: XCTestCase { parent: nil, debugLogger: nil ) - confidence.removeKey(key: "k2") + await confidence.removeContextAndWait(key: "k2") let expected = [ "k1": ConfidenceValue(string: "v1") ] XCTAssertEqual(confidence.getContext(), expected) } - func testRemoveContextEntryFromParent() { + func testRemoveContextEntryFromParent() async { let client = RemoteConfidenceResolveClient( options: ConfidenceClientOptions( credentials: ConfidenceClientCredentials.clientSecret(secret: ""), timeoutIntervalForRequest: 10), @@ -197,14 +197,14 @@ final class ConfidenceContextTests: XCTestCase { let confidenceChild: ConfidenceEventSender = confidenceParent.withContext( ["k2": ConfidenceValue(string: "v2")] ) - confidenceChild.removeKey(key: "k1") + await confidenceChild.removeContextAndWait(key: "k1") let expected = [ "k2": ConfidenceValue(string: "v2") ] XCTAssertEqual(confidenceChild.getContext(), expected) } - func testRemoveContextEntryFromParentAndChild() { + func testRemoveContextEntryFromParentAndChild() async { let client = RemoteConfidenceResolveClient( options: ConfidenceClientOptions( credentials: ConfidenceClientCredentials.clientSecret(secret: ""), timeoutIntervalForRequest: 10), @@ -228,14 +228,14 @@ final class ConfidenceContextTests: XCTestCase { "k1": ConfidenceValue(string: "v3"), ] ) - confidenceChild.removeKey(key: "k1") + await confidenceChild.removeContextAndWait(key: "k1") let expected = [ "k2": ConfidenceValue(string: "v2") ] XCTAssertEqual(confidenceChild.getContext(), expected) } - func testRemoveContextEntryFromParentAndChildThenUpdate() { + func testRemoveContextEntryFromParentAndChildThenUpdate() async { let client = RemoteConfidenceResolveClient( options: ConfidenceClientOptions( credentials: ConfidenceClientCredentials.clientSecret(secret: ""), timeoutIntervalForRequest: 10), @@ -259,8 +259,8 @@ final class ConfidenceContextTests: XCTestCase { "k1": ConfidenceValue(string: "v3"), ] ) - confidenceChild.removeKey(key: "k1") - confidenceChild.putContext(key: "k1", value: ConfidenceValue(string: "v4")) + await confidenceChild.removeContextAndWait(key: "k1") + await confidenceChild.putContextAndWait(key: "k1", value: ConfidenceValue(string: "v4")) let expected = [ "k2": ConfidenceValue(string: "v2"), "k1": ConfidenceValue(string: "v4"), diff --git a/Tests/ConfidenceTests/ConfidenceTest.swift b/Tests/ConfidenceTests/ConfidenceTest.swift index 51dc65d5..655d3143 100644 --- a/Tests/ConfidenceTests/ConfidenceTest.swift +++ b/Tests/ConfidenceTests/ConfidenceTest.swift @@ -10,7 +10,6 @@ import XCTest class ConfidenceTest: XCTestCase { private var flagApplier = FlagApplierMock() private let storage = StorageMock() - private var readyExpectation = XCTestExpectation(description: "Ready") override func setUp() { try? storage.clear() @@ -108,8 +107,8 @@ class ConfidenceTest: XCTestCase { XCTAssertEqual(2, client.resolveContexts.count) XCTAssertEqual(confidence.getContext(), client.resolveContexts[1]) } - // swiftlint:enable function_body_length + // swiftlint:enable function_body_length func testRefresh() async throws { class FakeClient: ConfidenceResolveClient { var resolveStats: Int = 0 @@ -142,14 +141,8 @@ class ConfidenceTest: XCTestCase { resolveReason: .match) ] - let expectation = expectation(description: "context is synced") - let cancellable = confidence.contextReconciliatedChanges.sink { _ in - expectation.fulfill() - } - confidence.putContext(context: ["targeting_key": .init(string: "user2")]) - await fulfillment(of: [expectation], timeout: 1) - cancellable.cancel() + await confidence.putContextAndWait(context: ["targeting_key": .init(string: "user2")]) let evaluation = confidence.getEvaluation( key: "flag.size", defaultValue: 0) @@ -376,8 +369,7 @@ class ConfidenceTest: XCTestCase { var resolvedValues: [ResolvedValue] = [] func resolve(ctx: ConfidenceStruct) async throws -> ResolvesResult { if self.resolveStats == 1 { - let expectation = expectation(description: "never fullfil") - await fulfillment(of: [expectation]) + throw ConfidenceError.internalError(message: "test") } self.resolveStats += 1 return .init(resolvedValues: resolvedValues, resolveToken: "token") @@ -400,7 +392,7 @@ class ConfidenceTest: XCTestCase { .build() try await confidence.fetchAndActivate() - confidence.putContext(context: ["hello": .init(string: "world")]) + await confidence.putContextAndWait(context: ["hello": .init(string: "world")]) let evaluation = confidence.getEvaluation( key: "flag.size", defaultValue: 0) @@ -457,6 +449,169 @@ class ConfidenceTest: XCTestCase { XCTAssertEqual(flagApplier.applyCallCount, 1) } + func testAwaitReconciliation() async throws { + class FakeClient: XCTestCase, ConfidenceResolveClient { + var resolveStats: Int = 0 + var resolvedValues: [ResolvedValue] = [] + func resolve(ctx: ConfidenceStruct) async throws -> ResolvesResult { + self.resolveStats += 1 + return .init(resolvedValues: resolvedValues, resolveToken: "token") + } + } + + let client = FakeClient() + client.resolvedValues = [ + ResolvedValue( + variant: "control", + value: .init(structure: ["size": .init(integer: 3)]), + flag: "flag", + resolveReason: .match) + ] + + let confidence = Confidence.Builder(clientSecret: "test") + .withContext(initialContext: ["targeting_key": .init(string: "user2")]) + .withFlagResolverClient(flagResolver: client) + .withFlagApplier(flagApplier: flagApplier) + .build() + confidence.putContext(context: ["hello": .init(string: "world")]) + await confidence.awaitReconciliation() + let evaluation = confidence.getEvaluation( + key: "flag.size", + defaultValue: 0) + + XCTAssertEqual(client.resolveStats, 1) + XCTAssertEqual(evaluation.value, 3) + XCTAssertNil(evaluation.errorCode) + XCTAssertNil(evaluation.errorMessage) + XCTAssertEqual(evaluation.reason, .match) + XCTAssertEqual(evaluation.variant, "control") + XCTAssertEqual(client.resolveStats, 1) + await fulfillment(of: [flagApplier.applyExpectation], timeout: 1) + XCTAssertEqual(flagApplier.applyCallCount, 1) + } + + func testAwaitReconciliationFailingTask() async throws { + class FakeClient: XCTestCase, ConfidenceResolveClient { + var resolveStats: Int = 0 + var resolvedValues: [ResolvedValue] = [] + + func resolve(ctx: ConfidenceStruct) async throws -> ResolvesResult { + self.resolveStats += 1 + if resolveStats == 1 { + // Delay to ensure the second putContext cancels this Task + try await Task.sleep(nanoseconds: 2_000_000_000) + XCTFail("This line shouldn't be reached as task is expected to be cancelled") + return .init(resolvedValues: [], resolveToken: "token") + } else { + if ctx["hello"] == .init(string: "world") { + return .init(resolvedValues: resolvedValues, resolveToken: "token") + } else { + return .init(resolvedValues: [], resolveToken: "token") + } + } + } + } + + let client = FakeClient() + client.resolvedValues = [ + ResolvedValue( + variant: "control", + value: .init(structure: ["size": .init(integer: 3)]), + flag: "flag", + resolveReason: .match) + ] + + let confidence = Confidence.Builder(clientSecret: "test") + .withContext(initialContext: ["targeting_key": .init(string: "user2")]) + .withFlagResolverClient(flagResolver: client) + .withFlagApplier(flagApplier: flagApplier) + .withStorage(storage: storage) + .build() + + confidence.putContext(context: ["hello": .init(string: "not-world")]) + try await Task.sleep(nanoseconds: 100_000_000) + Task { + confidence.putContext(context: ["hello": .init(string: "world")]) + } + try await Task.sleep(nanoseconds: 100_000_000) + await confidence.awaitReconciliation() + let evaluation = confidence.getEvaluation( + key: "flag.size", + defaultValue: 0 + ) + + XCTAssertEqual(client.resolveStats, 2) + XCTAssertEqual(evaluation.value, 3) + XCTAssertNil(evaluation.errorCode) + XCTAssertNil(evaluation.errorMessage) + XCTAssertEqual(evaluation.reason, .match) + XCTAssertEqual(evaluation.variant, "control") + await fulfillment(of: [flagApplier.applyExpectation], timeout: 1) + XCTAssertEqual(flagApplier.applyCallCount, 1) + } + + func testAwaitReconciliationFailingTaskAwait() async throws { + class FakeClient: XCTestCase, ConfidenceResolveClient { + var resolveStats: Int = 0 + var resolvedValues: [ResolvedValue] = [] + + func resolve(ctx: ConfidenceStruct) async throws -> ResolvesResult { + self.resolveStats += 1 + if resolveStats == 1 { + // Delay to ensure the second putContext cancels this Task + try await Task.sleep(nanoseconds: 2_000_000_000) + XCTFail("This line shouldn't be reached as task is expected to be cancelled") + return .init(resolvedValues: [], resolveToken: "token") + } else { + if ctx["hello"] == .init(string: "world") { + return .init(resolvedValues: resolvedValues, resolveToken: "token") + } else { + return .init(resolvedValues: [], resolveToken: "token") + } + } + } + } + + let client = FakeClient() + client.resolvedValues = [ + ResolvedValue( + variant: "control", + value: .init(structure: ["size": .init(integer: 3)]), + flag: "flag", + resolveReason: .match) + ] + + let confidence = Confidence.Builder(clientSecret: "test") + .withContext(initialContext: ["targeting_key": .init(string: "user2")]) + .withFlagResolverClient(flagResolver: client) + .withFlagApplier(flagApplier: flagApplier) + .withStorage(storage: storage) + .build() + + Task { + await confidence.putContextAndWait(context: ["hello": .init(string: "not-world")]) + } + try await Task.sleep(nanoseconds: 100_000_000) + Task { + await confidence.putContextAndWait(context: ["hello": .init(string: "world")]) + } + try await Task.sleep(nanoseconds: 100_000_000) + await confidence.awaitReconciliation() + let evaluation = confidence.getEvaluation( + key: "flag.size", + defaultValue: 0 + ) + + XCTAssertEqual(client.resolveStats, 2) + XCTAssertEqual(evaluation.value, 3) + XCTAssertNil(evaluation.errorCode) + XCTAssertNil(evaluation.errorMessage) + XCTAssertEqual(evaluation.reason, .match) + XCTAssertEqual(evaluation.variant, "control") + await fulfillment(of: [flagApplier.applyExpectation], timeout: 1) + XCTAssertEqual(flagApplier.applyCallCount, 1) + } + func testResolveBooleanFlag() async throws { class FakeClient: ConfidenceResolveClient { var resolveStats: Int = 0 @@ -697,15 +852,7 @@ class ConfidenceTest: XCTestCase { XCTAssertEqual(flagApplier.applyCallCount, 0) } - func testConcurrentActivate() async { - for _ in 1...100 { - Task { - await concurrentActivate() - } - } - } - - private func concurrentActivate() async { + func concurrentActivate() async { let confidence = Confidence.Builder(clientSecret: "test") .build() diff --git a/Tests/ConfidenceTests/Helpers/DebugLoggerFake.swift b/Tests/ConfidenceTests/Helpers/DebugLoggerFake.swift index 20d24a95..e2a53282 100644 --- a/Tests/ConfidenceTests/Helpers/DebugLoggerFake.swift +++ b/Tests/ConfidenceTests/Helpers/DebugLoggerFake.swift @@ -29,6 +29,14 @@ internal class DebugLoggerFake: DebugLogger { // no-op } + func logFlags(action: String, flag: String, resolveToken: String) { + // no-op + } + + func logFlags(action: String, context: ConfidenceStruct) { + // no-op + } + func getUploadBatchSuccessCount() -> Int { return uploadBatchSuccessCounter.get() } diff --git a/Tests/ConfidenceTests/TaskManagerTests.swift b/Tests/ConfidenceTests/TaskManagerTests.swift new file mode 100644 index 00000000..927c2aa6 --- /dev/null +++ b/Tests/ConfidenceTests/TaskManagerTests.swift @@ -0,0 +1,92 @@ +import Foundation +import XCTest +@testable import Confidence + +class TaskManagerTests: XCTestCase { + func testAwaitReconciliationCancelTask() async throws { + let signalManager = SignalManager() + let reconciliationExpectation = XCTestExpectation(description: "reconciliationExpectation") + let cancelTaskExpectation = XCTestExpectation(description: "cancelTaskExpectation") + let taskManager = TaskManager() + + let tenSeconds = Task { + do { + try await Task.sleep(nanoseconds: 10_000_000_000) + await signalManager.setSignal1(true) + } catch { + cancelTaskExpectation.fulfill() + } + } + taskManager.currentTask = tenSeconds + // Ensures the currentTask is set and has started + try await Task.sleep(nanoseconds: 100_000_000) + + Task { + await taskManager.awaitReconciliation() + reconciliationExpectation.fulfill() + } + tenSeconds.cancel() + await fulfillment(of: [cancelTaskExpectation, reconciliationExpectation], timeout: 1) + + let finalSignal1 = await signalManager.getSignal1() + + XCTAssertEqual(finalSignal1, false) + } + + func testOverrideTask() async throws { + let signalManager = SignalManager() + let cancelTaskExpectation = XCTestExpectation(description: "cancelTaskExpectation") + let secondTaskExpectation = XCTestExpectation(description: "secondTaskExpectation") + let taskManager = TaskManager() + + let tenSeconds1 = Task { + do { + try await Task.sleep(nanoseconds: 10_000_000_000) + await signalManager.setSignal1(true) + } catch { + cancelTaskExpectation.fulfill() + } + } + taskManager.currentTask = tenSeconds1 + // Ensures the currentTask is set and has started + try await Task.sleep(nanoseconds: 100_000_000) + + let tenSeconds2 = Task { + await signalManager.setSignal2(true) + secondTaskExpectation.fulfill() + } + taskManager.currentTask = tenSeconds2 + // Ensures the currentTask is set and has started + try await Task.sleep(nanoseconds: 100_000_000) + await taskManager.awaitReconciliation() + await fulfillment(of: [cancelTaskExpectation, secondTaskExpectation], timeout: 1) + + let finalSignal1 = await signalManager.getSignal1() + let finalSignal2 = await signalManager.getSignal2() + + XCTAssertEqual(finalSignal1, false) + XCTAssertEqual(finalSignal2, true) + } + + private actor SignalManager { + private var _signal1 = false + private var _signal2 = false + + // Functions to access and mutate `signal1` and `signal2` + func setSignal1(_ value: Bool) { + _signal1 = value + } + + func setSignal2(_ value: Bool) { + _signal2 = value + } + + func getSignal1() -> Bool { + return _signal1 + } + + func getSignal2() -> Bool { + return _signal2 + } + } +} diff --git a/api/Confidence_public_api.json b/api/Confidence_public_api.json index be9aaed5..141afc3e 100644 --- a/api/Confidence_public_api.json +++ b/api/Confidence_public_api.json @@ -12,7 +12,11 @@ }, { "name": "asyncFetch()", - "declaration": "public func asyncFetch()" + "declaration": "public func asyncFetch() async" + }, + { + "name": "isStorageEmpty()", + "declaration": "public func isStorageEmpty() -> Bool" }, { "name": "getEvaluation(key:defaultValue:)", @@ -23,24 +27,28 @@ "declaration": "public func getValue(key: String, defaultValue: T) -> T" }, { - "name": "contextChanges()", - "declaration": "public func contextChanges() -> AnyPublisher" + "name": "getContext()", + "declaration": "public func getContext() -> ConfidenceStruct" }, { - "name": "track(eventName:data:)", - "declaration": "public func track(eventName: String, data: ConfidenceStruct) throws" + "name": "putContextAndWait(key:value:)", + "declaration": "public func putContextAndWait(key: String, value: ConfidenceValue) async" }, { - "name": "track(producer:)", - "declaration": "public func track(producer: ConfidenceProducer)" + "name": "putContextAndWait(context:removedKeys:)", + "declaration": "public func putContextAndWait(context: ConfidenceStruct, removedKeys: [String] = []) async" }, { - "name": "flush()", - "declaration": "public func flush()" + "name": "putContextAndWait(context:)", + "declaration": "public func putContextAndWait(context: ConfidenceStruct) async" }, { - "name": "getContext()", - "declaration": "public func getContext() -> ConfidenceStruct" + "name": "removeContextAndWait(key:)", + "declaration": "public func removeContextAndWait(key: String) async" + }, + { + "name": "putContextLocal(context:removeKeys:)", + "declaration": "public func putContextLocal(context: ConfidenceStruct, removeKeys removedKeys: [String] = [])" }, { "name": "putContext(key:value:)", @@ -55,12 +63,32 @@ "declaration": "public func putContext(context: ConfidenceStruct, removeKeys removedKeys: [String] = [])" }, { - "name": "removeKey(key:)", - "declaration": "public func removeKey(key: String)" + "name": "removeContext(key:)", + "declaration": "public func removeContext(key: String)" + }, + { + "name": "putContext(context:removedKeys:)", + "declaration": "public func putContext(context: ConfidenceStruct, removedKeys: [String])" + }, + { + "name": "awaitReconciliation()", + "declaration": "public func awaitReconciliation() async" }, { "name": "withContext(_:)", "declaration": "public func withContext(_ context: ConfidenceStruct) -> ConfidenceEventSender" + }, + { + "name": "track(producer:)", + "declaration": "public func track(producer: ConfidenceProducer)" + }, + { + "name": "track(eventName:data:)", + "declaration": "public func track(eventName: String, data: ConfidenceStruct) throws" + }, + { + "name": "flush()", + "declaration": "public func flush()" } ] },