From 0d1cefdeb766a3d24c7d05be5f834d8855f271f3 Mon Sep 17 00:00:00 2001 From: vahidlazio Date: Thu, 25 Apr 2024 17:20:36 +0200 Subject: [PATCH] feat: add listening for context changes (#97) * add listening for context changes * fixup! merge master * move comment up * diff and remove the old keys that are not present * only 1 call is made after context diffing * sync context change actions using queue * fixup! sync context change actions using queue --- Sources/Confidence/Confidence.swift | 63 ++++++++++++-- Sources/Confidence/Contextual.swift | 2 +- Sources/Confidence/EventSenderEngine.swift | 3 + .../ConfidenceFeatureProvider.swift | 82 +++++++++---------- .../ConfidenceFeatureProviderTest.swift | 81 +++++++++++++++++- Tests/ConfidenceTests/ConfidenceTests.swift | 10 +-- 6 files changed, 186 insertions(+), 55 deletions(-) diff --git a/Sources/Confidence/Confidence.swift b/Sources/Confidence/Confidence.swift index 0eab326d..ecf98461 100644 --- a/Sources/Confidence/Confidence.swift +++ b/Sources/Confidence/Confidence.swift @@ -1,14 +1,16 @@ import Foundation +import Combine public class Confidence: ConfidenceEventSender { private let parent: ConfidenceContextProvider? - private var context: ConfidenceStruct public let clientSecret: String public var timeout: TimeInterval public var region: ConfidenceRegion let eventSenderEngine: EventSenderEngine public var initializationStrategy: InitializationStrategy + private let contextFlow = CurrentValueSubject([:]) private var removedContextKeys: Set = Set() + private let confidenceQueue = DispatchQueue(label: "com.confidence.queue") required init( clientSecret: String, @@ -24,33 +26,80 @@ public class Confidence: ConfidenceEventSender { self.timeout = timeout self.region = region self.initializationStrategy = initializationStrategy - self.context = context + self.contextFlow.value = context self.parent = parent } + public func contextChanges() -> AnyPublisher { + return contextFlow + .dropFirst() + .removeDuplicates() + .eraseToAnyPublisher() + } + public func track(eventName: String, message: ConfidenceStruct) { eventSenderEngine.emit(eventName: eventName, message: message, context: getContext()) } + private func withLock(callback: @escaping (Confidence) -> Void) { + confidenceQueue.sync { [weak self] in + guard let self = self else { + return + } + callback(self) + } + } + public func getContext() -> ConfidenceStruct { let parentContext = parent?.getContext() ?? [:] var reconciledCtx = parentContext.filter { !removedContextKeys.contains($0.key) } - self.context.forEach { entry in + self.contextFlow.value.forEach { entry in reconciledCtx.updateValue(entry.value, forKey: entry.key) } return reconciledCtx } - public func updateContextEntry(key: String, value: ConfidenceValue) { - context[key] = value + public func putContext(key: String, value: ConfidenceValue) { + withLock { confidence in + var map = confidence.contextFlow.value + map[key] = value + confidence.contextFlow.value = map + } + } + + public func putContext(context: ConfidenceStruct) { + withLock { confidence in + var map = confidence.contextFlow.value + for entry in context { + map.updateValue(entry.value, forKey: entry.key) + } + confidence.contextFlow.value = map + } + } + + public func putContext(context: ConfidenceStruct, removedKeys: [String] = []) { + withLock { confidence in + var map = confidence.contextFlow.value + for removedKey in removedKeys { + map.removeValue(forKey: removedKey) + } + for entry in context { + map.updateValue(entry.value, forKey: entry.key) + } + confidence.contextFlow.value = map + } } public func removeContextEntry(key: String) { - context.removeValue(forKey: key) - removedContextKeys.insert(key) + withLock { confidence in + var map = confidence.contextFlow.value + map.removeValue(forKey: key) + confidence.contextFlow.value = map + confidence.removedContextKeys.insert(key) + } } public func withContext(_ context: ConfidenceStruct) -> Self { diff --git a/Sources/Confidence/Contextual.swift b/Sources/Confidence/Contextual.swift index 554babd5..52fe6b73 100644 --- a/Sources/Confidence/Contextual.swift +++ b/Sources/Confidence/Contextual.swift @@ -5,7 +5,7 @@ import Foundation /// Each ConfidenceContextProvider returns local data reconciled with parents' data. Local data has precedence public protocol Contextual: ConfidenceContextProvider { /// Adds/override entry to local data - func updateContextEntry(key: String, value: ConfidenceValue) + func putContext(key: String, value: ConfidenceValue) /// Removes entry from local data /// It hides entries with this key from parents' data (without modifying parents' data) func removeContextEntry(key: String) diff --git a/Sources/Confidence/EventSenderEngine.swift b/Sources/Confidence/EventSenderEngine.swift index a39d4087..e1584da8 100644 --- a/Sources/Confidence/EventSenderEngine.swift +++ b/Sources/Confidence/EventSenderEngine.swift @@ -89,6 +89,9 @@ final class EventSenderEngineImpl: EventSenderEngine { } func shutdown() { + for cancellable in cancellables { + cancellable.cancel() + } cancellables.removeAll() } } diff --git a/Sources/ConfidenceProvider/ConfidenceFeatureProvider.swift b/Sources/ConfidenceProvider/ConfidenceFeatureProvider.swift index c719eb87..744906cb 100644 --- a/Sources/ConfidenceProvider/ConfidenceFeatureProvider.swift +++ b/Sources/ConfidenceProvider/ConfidenceFeatureProvider.swift @@ -24,6 +24,7 @@ public class ConfidenceFeatureProvider: FeatureProvider { private let storage: Storage private let eventHandler = EventHandler(ProviderEvent.notReady) private let confidence: Confidence? + private var cancellables = Set() /// Should not be called externally, use `ConfidenceFeatureProvider.Builder`or init with `Confidence` instead. init( @@ -47,12 +48,12 @@ public class ConfidenceFeatureProvider: FeatureProvider { self.resolver = LocalStorageResolver(cache: cache) } + /// Initialize the Provider via a `Confidence` object. public convenience init(confidence: Confidence) { - self.init(confidence: confidence, session: nil) + self.init(confidence: confidence, session: nil, client: nil) } - /// Initialize the Provider via a `Confidence` object. - internal init(confidence: Confidence, session: URLSession? = nil) { + internal init(confidence: Confidence, session: URLSession?, client: ConfidenceResolveClient?) { let metadata = ConfidenceMetadata(version: "0.1.4") // x-release-please-version let options = ConfidenceClientOptions( credentials: ConfidenceClientCredentials.clientSecret(secret: confidence.clientSecret), @@ -67,7 +68,7 @@ public class ConfidenceFeatureProvider: FeatureProvider { storage: DefaultStorage.applierFlagsCache(), options: options, metadata: metadata) - self.client = RemoteConfidenceResolveClient( + self.client = client ?? RemoteConfidenceResolveClient( options: options, session: session, applyOnResolve: false, @@ -88,10 +89,16 @@ public class ConfidenceFeatureProvider: FeatureProvider { eventHandler.send(.ready) } + let context = confidence?.getContext() ?? ConfidenceTypeMapper.from(ctx: initialContext) + + resolve(strategy: initializationStrategy, context: context) + self.startListentingForContextChanges() + } + + private func resolve(strategy: InitializationStrategy, context: ConfidenceStruct) { Task { do { - let context = confidence?.getContext() ?? ConfidenceTypeMapper.from(ctx: initialContext) - let resolveResult = try await resolve(context: context) + let resolveResult = try await client.resolve(ctx: context) // update cache with stored values try await store( @@ -111,6 +118,13 @@ public class ConfidenceFeatureProvider: FeatureProvider { } } + func shutdown() { + for cancellable in cancellables { + cancellable.cancel() + } + cancellables.removeAll() + } + private func store( with context: ConfidenceStruct, resolveResult result: ResolvesResult, @@ -132,38 +146,35 @@ public class ConfidenceFeatureProvider: FeatureProvider { oldContext: OpenFeature.EvaluationContext?, newContext: OpenFeature.EvaluationContext ) { - var oldConfidenceContext: ConfidenceStruct = [:] - if let context = oldContext { - oldConfidenceContext = ConfidenceTypeMapper.from(ctx: context) - } - guard oldConfidenceContext.hash() != ConfidenceTypeMapper.from(ctx: newContext).hash() else { + if confidence == nil { + self.resolve(strategy: .fetchAndActivate, context: ConfidenceTypeMapper.from(ctx: newContext)) return } - self.updateConfidenceContext(context: newContext) - Task { - do { - let context = confidence?.getContext() ?? ConfidenceTypeMapper.from(ctx: newContext) - let resolveResult = try await resolve(context: context) + var removedKeys: [String] = [] + if let oldContext = oldContext { + removedKeys = Array(oldContext.asMap().filter { key, _ in !newContext.asMap().keys.contains(key) }.keys) + } - // update the storage - try await store(with: context, resolveResult: resolveResult, refreshCache: true) + self.updateConfidenceContext(context: newContext, removedKeys: removedKeys) + } - eventHandler.send(ProviderEvent.ready) - } catch { - eventHandler.send(ProviderEvent.ready) - // do nothing - } + private func startListentingForContextChanges() { + guard let confidence = confidence else { + return } + confidence.contextChanges() + .sink { [weak self] context in + guard let self = self else { + return + } + self.resolve(strategy: self.initializationStrategy, context: context) + } + .store(in: &cancellables) } - private func updateConfidenceContext(context: EvaluationContext) { - for entry in ConfidenceTypeMapper.from(ctx: context) { - confidence?.updateContextEntry( - key: entry.key, - value: entry.value - ) - } + private func updateConfidenceContext(context: EvaluationContext, removedKeys: [String] = []) { + confidence?.putContext(context: ConfidenceTypeMapper.from(ctx: context), removedKeys: removedKeys) } public func getBooleanEvaluation(key: String, defaultValue: Bool, context: EvaluationContext?) throws @@ -234,17 +245,6 @@ public class ConfidenceFeatureProvider: FeatureProvider { } } - private func resolve(context: ConfidenceStruct) async throws -> ResolvesResult { - do { - let resolveResult = try await client.resolve(ctx: context) - return resolveResult - } catch { - Logger(subsystem: "com.confidence.provider", category: "initialize").error( - "Error while executing \"initialize\": \(error)") - throw error - } - } - public func errorWrappedResolveFlag(flag: String, defaultValue: T, ctx: EvaluationContext?, errorPrefix: String) throws -> ProviderEvaluation { diff --git a/Tests/ConfidenceProviderTests/ConfidenceFeatureProviderTest.swift b/Tests/ConfidenceProviderTests/ConfidenceFeatureProviderTest.swift index 338834df..bb4ea94c 100644 --- a/Tests/ConfidenceProviderTests/ConfidenceFeatureProviderTest.swift +++ b/Tests/ConfidenceProviderTests/ConfidenceFeatureProviderTest.swift @@ -326,7 +326,7 @@ class ConfidenceFeatureProviderTest: XCTestCase { .build() .withContext(["my_string": ConfidenceValue(string: "my_value")]) - let provider = ConfidenceFeatureProvider(confidence: confidence, session: session) + let provider = ConfidenceFeatureProvider(confidence: confidence, session: session, client: nil) try withExtendedLifetime( provider.observe().sink { event in @@ -913,6 +913,54 @@ class ConfidenceFeatureProviderTest: XCTestCase { } } + func testRemovedKeyWillbeRemovedFromConfidenceContext() { + let expectationOneCall = expectation(description: "one call is made") + let twoCallsExpectation = expectation(description: "two calls is made") + class FakeClient: ConfidenceResolveClient { + var callCount = 0 + var oneCallExpectation: XCTestExpectation + var twoCallsExpectation: XCTestExpectation + init(oneCallExpectation: XCTestExpectation, twoCallsExpectation: XCTestExpectation) { + self.oneCallExpectation = oneCallExpectation + self.twoCallsExpectation = twoCallsExpectation + } + + func resolve(ctx: ConfidenceStruct) async throws -> ResolvesResult { + callCount += 1 + if callCount == 1 { + self.oneCallExpectation.fulfill() + } else if callCount == 2 { + self.twoCallsExpectation.fulfill() + } + return .init(resolvedValues: [], resolveToken: "") + } + } + + let confidence = Confidence.Builder.init(clientSecret: "").build() + let client = FakeClient(oneCallExpectation: expectationOneCall, twoCallsExpectation: twoCallsExpectation) + let provider = ConfidenceFeatureProvider(confidence: confidence, session: nil, client: client) + let initialContext = MutableContext(targetingKey: "user1") + .add(key: "hello", value: Value.string("world")) + provider.initialize(initialContext: initialContext) + let expectedInitialContext = [ + "targeting_key": ConfidenceValue(string: "user1"), + "hello": ConfidenceValue(string: "world") + ] + XCTAssertEqual(confidence.getContext(), expectedInitialContext) + let expectedNewContext = [ + "targeting_key": ConfidenceValue(string: "user1"), + "new": ConfidenceValue(string: "west world") + ] + let newContext = MutableContext(targetingKey: "user1") + .add(key: "new", value: Value.string("west world")) + wait(for: [expectationOneCall], timeout: 1) + XCTAssertEqual(1, client.callCount) + provider.onContextSet(oldContext: initialContext, newContext: newContext) + XCTAssertEqual(confidence.getContext(), expectedNewContext) + wait(for: [twoCallsExpectation], timeout: 1) + XCTAssertEqual(2, client.callCount) + } + func testOverridingInProvider() throws { let resolve: [String: MockedResolveClientURLProtocol.ResolvedTestFlag] = [ "user1": .init(variant: "control", value: .structure(["size": .integer(3)])) @@ -1000,6 +1048,37 @@ class ConfidenceFeatureProviderTest: XCTestCase { XCTAssertEqual(context, expected) } } + + func testConfidenceContextOnContextChangeThroughConfidence() throws { + class FakeClient: ConfidenceResolveClient { + var callCount = 0 + func resolve(ctx: ConfidenceStruct) async throws -> ResolvesResult { + callCount += 1 + return .init(resolvedValues: [], resolveToken: "") + } + } + + let confidence = Confidence.Builder.init(clientSecret: "").build() + let client = FakeClient() + let provider = ConfidenceFeatureProvider(confidence: confidence, session: nil, client: client) + + let readyExpectation = self.expectation(description: "Waiting for init and ctx change to complete") + readyExpectation.expectedFulfillmentCount = 2 + + withExtendedLifetime( + provider.observe().sink { event in + if event == .ready { + readyExpectation.fulfill() + } + }) + { + let ctx1 = MutableContext(targetingKey: "user1") + provider.initialize(initialContext: ctx1) + confidence.putContext(key: "active", value: ConfidenceValue.init(boolean: true)) + wait(for: [readyExpectation], timeout: 5) + XCTAssertEqual(client.callCount, 2) + } + } } final class DispatchQueueFake: DispatchQueueType { diff --git a/Tests/ConfidenceTests/ConfidenceTests.swift b/Tests/ConfidenceTests/ConfidenceTests.swift index a5bbaad0..cd0035d6 100644 --- a/Tests/ConfidenceTests/ConfidenceTests.swift +++ b/Tests/ConfidenceTests/ConfidenceTests.swift @@ -34,7 +34,7 @@ final class ConfidenceTests: XCTestCase { let confidenceChild: ConfidenceEventSender = confidenceParent.withContext( ["k2": ConfidenceValue(string: "v2")] ) - confidenceParent.updateContextEntry( + confidenceParent.putContext( key: "k3", value: ConfidenceValue(string: "v3")) let expected = [ @@ -55,7 +55,7 @@ final class ConfidenceTests: XCTestCase { context: ["k1": ConfidenceValue(string: "v1")], parent: nil ) - confidence.updateContextEntry( + confidence.putContext( key: "k1", value: ConfidenceValue(string: "v3")) let expected = [ @@ -77,7 +77,7 @@ final class ConfidenceTests: XCTestCase { let confidenceChild: ConfidenceEventSender = confidenceParent.withContext( ["k2": ConfidenceValue(string: "v2")] ) - confidenceChild.updateContextEntry( + confidenceChild.putContext( key: "k2", value: ConfidenceValue(string: "v4")) let expected = [ @@ -100,7 +100,7 @@ final class ConfidenceTests: XCTestCase { let confidenceChild: ConfidenceEventSender = confidenceParent.withContext( ["k2": ConfidenceValue(string: "v2")] ) - confidenceParent.updateContextEntry( + confidenceParent.putContext( key: "k2", value: ConfidenceValue(string: "v4")) let expected = [ @@ -190,7 +190,7 @@ final class ConfidenceTests: XCTestCase { ] ) confidenceChild.removeContextEntry(key: "k1") - confidenceChild.updateContextEntry(key: "k1", value: ConfidenceValue(string: "v4")) + confidenceChild.putContext(key: "k1", value: ConfidenceValue(string: "v4")) let expected = [ "k2": ConfidenceValue(string: "v2"), "k1": ConfidenceValue(string: "v4"),