Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add listening for context changes #97

Merged
merged 8 commits into from
Apr 25, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OF.init -> "context1" = 1
Confidence.putContext -> "context1" = 2
OF.init -> "context2" = "abc"

In this case, the final context won't have context1 but we would expect it, right?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would say no. if you had some context in your OF and now you set it again without, it means you removed it. I'd remove it here too. (keeping the current behaviour)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isn't Confidence (line 2) overriding context1? At this point, that takes precedence

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes but the set of context using open feature api is also an action which happens later, which takes over the priority again.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

basically the chain of commands from the confidence perspective is :
add context 1 -> commander : OF
change context 1 -> commander: Confidence API
remove context 1 and add context 2 -> commander OF

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, I see 👍

}

// 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
Loading