Skip to content

Commit

Permalink
feat: add listening for context changes (#97)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
vahidlazio committed Apr 25, 2024
1 parent a7cbb19 commit 0d1cefd
Show file tree
Hide file tree
Showing 6 changed files with 186 additions and 55 deletions.
63 changes: 56 additions & 7 deletions Sources/Confidence/Confidence.swift
Original file line number Diff line number Diff line change
@@ -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<ConfidenceStruct, Never>([:])
private var removedContextKeys: Set<String> = Set()
private let confidenceQueue = DispatchQueue(label: "com.confidence.queue")

required init(
clientSecret: String,
Expand All @@ -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<ConfidenceStruct, Never> {
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 {
Expand Down
2 changes: 1 addition & 1 deletion Sources/Confidence/Contextual.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
3 changes: 3 additions & 0 deletions Sources/Confidence/EventSenderEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,9 @@ final class EventSenderEngineImpl: EventSenderEngine {
}

func shutdown() {
for cancellable in cancellables {
cancellable.cancel()
}
cancellables.removeAll()
}
}
Expand Down
82 changes: 41 additions & 41 deletions Sources/ConfidenceProvider/ConfidenceFeatureProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<AnyCancellable>()

/// Should not be called externally, use `ConfidenceFeatureProvider.Builder`or init with `Confidence` instead.
init(
Expand All @@ -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),
Expand All @@ -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,
Expand All @@ -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(
Expand All @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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<T>(flag: String, defaultValue: T, ctx: EvaluationContext?, errorPrefix: String)
throws -> ProviderEvaluation<T>
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)]))
Expand Down Expand Up @@ -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 {
Expand Down
Loading

0 comments on commit 0d1cefd

Please sign in to comment.