From defbc1257bcbf9da828b074e6ae4ae4cb8dbe0e0 Mon Sep 17 00:00:00 2001 From: Fabrizio Demaria Date: Wed, 4 Dec 2024 17:09:29 +0100 Subject: [PATCH] feat: Safe access to context via ContextManager --- Sources/Confidence/Confidence.swift | 304 +++++++++--------- .../Confidence/ConfidenceEventSender.swift | 11 + .../ConfidenceFeatureProvider.swift | 4 +- 3 files changed, 159 insertions(+), 160 deletions(-) diff --git a/Sources/Confidence/Confidence.swift b/Sources/Confidence/Confidence.swift index d60b762c..dc08f471 100644 --- a/Sources/Confidence/Confidence.swift +++ b/Sources/Confidence/Confidence.swift @@ -3,26 +3,29 @@ 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 let debugLogger: DebugLogger? + private let cacheQueue = DispatchQueue(label: "com.confidence.queue.cache") private let semaphore = DispatchSemaphore(value: 1) // Internal for testing internal let remoteFlagResolver: ConfidenceResolveClient - internal let contextReconciliatedChanges = PassthroughSubject() public static let sdkId: String = "SDK_ID_SWIFT_CONFIDENCE" @@ -42,9 +45,8 @@ 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 @@ -74,14 +76,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() } @@ -89,20 +84,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( @@ -113,6 +106,13 @@ public class Confidence: ConfidenceEventSender { 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" @@ -148,73 +148,9 @@ public class Confidence: ConfidenceEventSender { return getEvaluation(key: key, defaultValue: defaultValue).value } - func isStorageEmpty() -> Bool { - return storage.isEmpty() - } - - /** - Listen to changes in the context that is local to this Confidence instance. - */ - public func contextChanges() -> AnyPublisher { - return contextSubject - .dropFirst() - .removeDuplicates() - .eraseToAnyPublisher() - } - - public func track(eventName: String, data: ConfidenceStruct) throws { - try eventSenderEngine.emit( - eventName: eventName, - data: data, - context: getContext() - ) - } - - public func track(producer: ConfidenceProducer) { - if let eventProducer = producer as? ConfidenceEventProducer { - eventProducer.produceEvents() - .sink { [weak self] event in - guard let self = self else { - return - } - do { - try self.track(eventName: event.name, data: event.data) - if event.shouldFlush { - eventSenderEngine.flush() - } - } catch { - Logger(subsystem: "com.confidence", category: "track").warning( - "Error from EventProducer, failed to track event: \(event.name)") - } - } - .store(in: &cancellables) - } - - if let contextProducer = producer as? ConfidenceContextProducer { - contextProducer.produceContexts() - .sink { [weak self] context in - Task { [weak self] in - guard let self = self else { return } - await self.putContext(context: context) - } - } - .store(in: &cancellables) - } - } - - 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 + let parentContext = parentContextProvider?.getContext() ?? [:] + return contextManager.getContext(parentContext: parentContext) } public func putContext(key: String, value: ConfidenceValue) async { @@ -222,36 +158,28 @@ public class Confidence: ConfidenceEventSender { guard let self = self else { return } - var map = contextSubject.value - map[key] = value - contextSubject.value = map + let newContext = contextManager.updateContext(withValues: [key: value], removedKeys: []) do { try await self.fetchAndActivate() - debugLogger?.logContext(action: "PutContext", context: contextSubject.value) + debugLogger?.logContext(action: "PutContext", context: newContext) } catch { debugLogger?.logMessage(message: "Error when putting context: \(error)", isWarning: true) } } } - - public func putContextLocal(context: ConfidenceStruct, removeKeys removedKeys: [String] = []) { - // Maybe use the semaphore instead - withSemaphore { [weak self] in + public func putContext(context: ConfidenceStruct, removedKeys: [String] = []) async { + await withSemaphoreAsync { [weak self] in guard let self = self else { return } - var map = contextSubject.value - for removedKey in removedKeys { - map.removeValue(forKey: removedKey) - } - for entry in context { - map.updateValue(entry.value, forKey: entry.key) + 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) } - contextSubject.value = map - debugLogger?.logContext( - action: "PutContextLocal", - context: contextSubject.value) } } @@ -260,16 +188,12 @@ public class Confidence: ConfidenceEventSender { guard let self = self else { return } - var map = contextSubject.value - for entry in context { - map.updateValue(entry.value, forKey: entry.key) - } - contextSubject.value = map + let newContext = contextManager.updateContext(withValues: context, removedKeys: []) do { try await fetchAndActivate() debugLogger?.logContext( - action: "PutContext - Done with FetchAndActivate", - context: contextSubject.value) + action: "PutContext", + context: newContext) } catch { debugLogger?.logMessage( message: "Error when putting context: \(error)", @@ -278,30 +202,14 @@ public class Confidence: ConfidenceEventSender { } } - public func putContext(context: ConfidenceStruct, removeKeys removedKeys: [String] = []) async { - await withSemaphoreAsync { [weak self] in - guard let self = self else { - return - } - var map = contextSubject.value - for removedKey in removedKeys { - map.removeValue(forKey: removedKey) - } - for entry in context { - map.updateValue(entry.value, forKey: entry.key) - } - contextSubject.value = map - do { - try await self.fetchAndActivate() - debugLogger?.logContext( - action: "PutContext - Done with FetchAndActivate", - context: contextSubject.value) - } catch { - debugLogger?.logMessage( - message: "Error when putting context: \(error)", - isWarning: true) - } - } + /** + Adds/override entry to local context data. Does not trigger fetchAndActivate after the context change. + */ + public func putContextLocal(context: ConfidenceStruct, removeKeys removedKeys: [String] = []) { + let newContext = contextManager.updateContext(withValues: context, removedKeys: removedKeys) + debugLogger?.logContext( + action: "PutContextLocal", + context: newContext) } public func removeContext(key: String) async { @@ -309,15 +217,12 @@ public class Confidence: ConfidenceEventSender { guard let self = self else { return } - var map = contextSubject.value - map.removeValue(forKey: key) - contextSubject.value = map - removedContextKeys.insert(key) + let newContext = contextManager.updateContext(withValues: [:], removedKeys: [key]) do { try await self.fetchAndActivate() debugLogger?.logContext( - action: "RemoveContext - Done with FetchAndActivate", - context: contextSubject.value) + action: "RemoveContext", + context: newContext) } catch { debugLogger?.logMessage( message: "Error when removing context key: \(error)", @@ -340,7 +245,51 @@ public class Confidence: ConfidenceEventSender { ) } - func withSemaphoreAsync(callback: @escaping () async -> Void) async { + public func track(producer: ConfidenceProducer) { + if let eventProducer = producer as? ConfidenceEventProducer { + eventProducer.produceEvents() + .sink { [weak self] event in + guard let self = self else { + return + } + do { + try self.track(eventName: event.name, data: event.data) + if event.shouldFlush { + eventSenderEngine.flush() + } + } catch { + Logger(subsystem: "com.confidence", category: "track").warning( + "Error from EventProducer, failed to track event: \(event.name)") + } + } + .store(in: &cancellables) + } + + if let contextProducer = producer as? ConfidenceContextProducer { + contextProducer.produceContexts() + .sink { [weak self] context in + Task { [weak self] in + guard let self = self else { return } + await 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() + } + + private func withSemaphoreAsync(callback: @escaping () async -> Void) async { await withCheckedContinuation { continuation in DispatchQueue.global().async { self.semaphore.wait() @@ -350,11 +299,48 @@ public class Confidence: ConfidenceEventSender { await callback() semaphore.signal() } +} - func withSemaphore(callback: @escaping () -> Void) { - self.semaphore.wait() - callback() - semaphore.signal() +private class ContextManager { + private var context: ConfidenceStruct = [:] + private var removedContextKeys: Set = Set() + private let contextQueue = DispatchQueue(label: "com.confidence.queue.context") + + public init(initialContext: ConfidenceStruct) { + context = initialContext + } + + func updateContext(withValues: ConfidenceStruct, removedKeys: [String]) -> ConfidenceStruct { + contextQueue.sync { [weak self] in + guard let self = self else { + return [:] + } + var map = self.context + for removedKey in removedKeys { + map.removeValue(forKey: removedKey) + removedContextKeys.insert(removedKey) + } + for entry in withValues { + map.updateValue(entry.value, forKey: entry.key) + } + self.context = map + return self.context + } + } + + func getContext(parentContext: ConfidenceStruct) -> ConfidenceStruct { + contextQueue.sync { [weak self] in + guard let self = self else { + return [:] + } + var reconciledCtx = parentContext.filter { + !self.removedContextKeys.contains($0.key) + } + context.forEach { entry in + reconciledCtx.updateValue(entry.value, forKey: entry.key) + } + return reconciledCtx + } } } diff --git a/Sources/Confidence/ConfidenceEventSender.swift b/Sources/Confidence/ConfidenceEventSender.swift index f595a6e9..cc6a23d0 100644 --- a/Sources/Confidence/ConfidenceEventSender.swift +++ b/Sources/Confidence/ConfidenceEventSender.swift @@ -20,14 +20,25 @@ public protocol ConfidenceEventSender: ConfidenceContextProvider { func flush() /** Adds/override entry to local context data + Triggers fetchAndActivate after the context change */ func putContext(key: String, value: ConfidenceValue) async /** + Adds/override entry to local context data + Triggers fetchAndActivate after the context change + */ + func putContext(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 removeContext(key: String) async /** + Combination of putContext and removeContext + */ + func putContext(context: ConfidenceStruct, removedKeys: [String]) async + /** Creates a child event sender instance that maintains access to its parent's data */ func withContext(_ context: ConfidenceStruct) -> ConfidenceEventSender diff --git a/Sources/ConfidenceProvider/ConfidenceFeatureProvider.swift b/Sources/ConfidenceProvider/ConfidenceFeatureProvider.swift index 7f86d576..d6fbf457 100644 --- a/Sources/ConfidenceProvider/ConfidenceFeatureProvider.swift +++ b/Sources/ConfidenceProvider/ConfidenceFeatureProvider.swift @@ -46,7 +46,9 @@ public class ConfidenceFeatureProvider: FeatureProvider { if initializationStrategy == .activateAndFetchAsync { try confidence.activate() eventHandler.send(.ready) - confidence.asyncFetch() + Task { + await confidence.asyncFetch() + } } else { Task { do {