Skip to content

Commit

Permalink
refactor: Make setting provder / ctx sequential for safety
Browse files Browse the repository at this point in the history
  • Loading branch information
fabriziodemaria committed Dec 17, 2024
1 parent fe1b609 commit 16499c7
Show file tree
Hide file tree
Showing 5 changed files with 109 additions and 80 deletions.
169 changes: 98 additions & 71 deletions Sources/OpenFeature/OpenFeatureAPI.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,36 +4,48 @@ import Foundation
/// A global singleton which holds base configuration for the OpenFeature library.
/// Configuration here will be shared across all ``Client``s.
public class OpenFeatureAPI {
private var stateManager = SafeStateManager()
private let eventHandler = EventHandler()
private var stateManager: SafeStateManager
private let eventHandler: EventHandler
private(set) var hooks: [any Hook] = []

/// The ``OpenFeatureAPI`` singleton
static public let shared = OpenFeatureAPI()

public init() {
self.eventHandler = EventHandler()
self.stateManager = SafeStateManager(eventHandler: eventHandler)
}

/**
Set provider and calls its `initialize` in a background thread.
Readiness can be determined from `getState` or listening for `ready` event.
*/
public func setProvider(provider: FeatureProvider, initialContext: EvaluationContext?) {
stateManager.setProviderAsync(provider: provider, initialContext: initialContext)
}

/**
Set provider and calls its `initialize`.
This async function returns when the initalize from the provider is completed.
*/
public func setProviderAndWait(provider: FeatureProvider, initialContext: EvaluationContext?) async {
await stateManager.setProviderBlocking(provider: provider, initialContext: initialContext)
}

/**
Set provider and calls its `initialize` in a background thread.
Readiness can be determined from `getState` or listening for `ready` event.
*/
public func setProvider(provider: FeatureProvider) {
self.setProvider(provider: provider, initialContext: nil)
setProvider(provider: provider, initialContext: nil)
}

public func setProvider(provider: FeatureProvider, initialContext: EvaluationContext?) {
stateManager.setProvider(provider: provider, initialContext: initialContext)
do {
try provider.initialize(initialContext: initialContext)
stateManager.update(providerStatus: .ready)
eventHandler.send(.ready)
} catch {
switch error {
case OpenFeatureError.providerFatalError:
stateManager.update(providerStatus: .fatal)
eventHandler.send(.error(errorCode: .providerFatal))
default:
stateManager.update(providerStatus: .error)
eventHandler.send(.error(message: error.localizedDescription))
}
}
/**
Set provider and calls its `initialize`.
This async function returns when the initalize from the provider is completed.
*/
public func setProviderAndWait(provider: FeatureProvider) async {
await setProviderAndWait(provider: provider, initialContext: nil)
}

public func getProvider() -> FeatureProvider? {
Expand All @@ -45,17 +57,7 @@ public class OpenFeatureAPI {
}

public func setEvaluationContext(evaluationContext: EvaluationContext) {
do {
let oldContext = self._context
stateManager.update(evaluationContext: evaluationContext, providerStatus: .reconciling)
eventHandler.send(.reconciling)
try getProvider()?.onContextSet(oldContext: oldContext, newContext: evaluationContext)
stateManager.update(providerStatus: .ready)
eventHandler.send(.contextChanged)
} catch {
stateManager.update(providerStatus: .error)
eventHandler.send(.error(message: error.localizedDescription))
}
stateManager.update(evaluationContext: evaluationContext)
}

public func getEvaluationContext() -> EvaluationContext? {
Expand Down Expand Up @@ -86,7 +88,7 @@ public class OpenFeatureAPI {
self.hooks.removeAll()
}

public func getState() -> (
internal func getState() -> (

Check warning on line 91 in Sources/OpenFeature/OpenFeatureAPI.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

Large Tuple Violation: Tuples should have at most 2 members (large_tuple)
provider: FeatureProvider?, evaluationContext: EvaluationContext?, providerStatus: ProviderStatus
) {
return self.stateManager.getState()
Expand Down Expand Up @@ -124,63 +126,88 @@ extension OpenFeatureAPI {
}
}

extension OpenFeatureAPI {
public func setProviderAndWait(provider: FeatureProvider) async {
await setProviderAndWait(provider: provider, initialContext: nil)
}

public func setProviderAndWait(provider: FeatureProvider, initialContext: EvaluationContext?) async {
let task = Task {
var holder: [AnyCancellable] = []
await withCheckedContinuation { continuation in
setProvider(provider: provider, initialContext: initialContext)
continuation.resume()
holder.removeAll()
}
}
await withTaskCancellationHandler {
await task.value
} onCancel: {
task.cancel()
}
}
}

/// This helper struct maintains the provider, its state and the global evaluation context
/// It is designed to be thread safe on write: context and status are updated atomically, for example.
/// The allowed bulk-changes are also executed in a serial fashion to guarantee thread-safety.
struct SafeStateManager {
class SafeStateManager {
private let queue = DispatchQueue(label: "com.providerDescriptor.queue")

private(set) var provider: FeatureProvider?
private(set) var providerSubject = CurrentValueSubject<FeatureProvider?, Never>(nil)
private(set) var evaluationContext: EvaluationContext? = nil
private(set) var providerStatus: ProviderStatus = .notReady
private(set) var providerSubject: CurrentValueSubject<(any FeatureProvider)?, Never>
private(set) var evaluationContext: EvaluationContext?
private(set) var providerStatus: ProviderStatus

mutating func setProvider(provider: FeatureProvider, initialContext: EvaluationContext? = nil) {
queue.sync {
self.provider = provider
self.providerStatus = .notReady
if let initialContext = initialContext {
self.evaluationContext = initialContext
}
providerSubject.send(provider)
private let eventHandler: EventHandler


init(provider: FeatureProvider? = nil,

Check warning on line 143 in Sources/OpenFeature/OpenFeatureAPI.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

Multiline Parameters Brackets Violation: Multiline parameters should have their surrounding brackets in a new line (multiline_parameters_brackets)
providerSubject: CurrentValueSubject<(any FeatureProvider)?, Never> = CurrentValueSubject<FeatureProvider?, Never>(nil),

Check warning on line 144 in Sources/OpenFeature/OpenFeatureAPI.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

Indentation Width Violation: Code should be indented using one tab or 4 spaces (indentation_width)

Check warning on line 144 in Sources/OpenFeature/OpenFeatureAPI.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

Line Length Violation: Line should be 120 characters or less; currently it has 129 characters (line_length)
evaluationContext: EvaluationContext? = nil,
providerStatus: ProviderStatus = .notReady,
eventHandler: EventHandler)

Check warning on line 147 in Sources/OpenFeature/OpenFeatureAPI.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

Multiline Parameters Brackets Violation: Multiline parameters should have their surrounding brackets in a new line (multiline_parameters_brackets)
{
self.provider = provider
self.providerSubject = providerSubject
self.evaluationContext = evaluationContext
self.providerStatus = providerStatus
self.eventHandler = eventHandler
}

func setProviderAsync(provider: FeatureProvider, initialContext: EvaluationContext? = nil) {
queue.async {
self.setProviderInternal(provider: provider, initialContext: initialContext)
}
}

mutating func update(evaluationContext: EvaluationContext? = nil, providerStatus: ProviderStatus? = nil) {
func setProviderBlocking(provider: FeatureProvider, initialContext: EvaluationContext? = nil) async {
queue.sync {
if let newContext = evaluationContext {
self.evaluationContext = newContext
self.setProviderInternal(provider: provider, initialContext: initialContext)
}
}

private func setProviderInternal(provider: FeatureProvider, initialContext: EvaluationContext? = nil) {
self.provider = provider
self.providerStatus = .notReady
self.providerSubject.send(provider)

if let initialContext = initialContext {
self.evaluationContext = initialContext
}

do {
try provider.initialize(initialContext: initialContext)
self.providerStatus = .ready
self.eventHandler.send(.ready)
} catch {
switch error {
case OpenFeatureError.providerFatalError:
self.providerStatus = .fatal
self.eventHandler.send(.error(errorCode: .providerFatal))
default:
self.providerStatus = .error
self.eventHandler.send(.error(message: error.localizedDescription))
}
}
}

if let newStatus = providerStatus {
self.providerStatus = newStatus
func update(evaluationContext: EvaluationContext) {
queue.sync {
do {
let oldContext = self.evaluationContext
self.evaluationContext = evaluationContext
self.providerStatus = .reconciling
eventHandler.send(.reconciling)
try self.provider?.onContextSet(oldContext: oldContext, newContext: evaluationContext)
self.providerStatus = .ready
eventHandler.send(.contextChanged)
} catch {
self.providerStatus = .error
eventHandler.send(.error(message: error.localizedDescription))
}
}
}

mutating func clearProvider() {
func clearProvider() {
queue.sync {
self.provider = nil
self.providerSubject.send(nil)
Expand Down
8 changes: 6 additions & 2 deletions Sources/OpenFeature/OpenFeatureClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,9 @@ extension OpenFeatureClient {
switch openFeatureApiState.providerStatus {
case .fatal:
details.errorCode = .providerFatal
details.errorMessage = OpenFeatureError.providerFatalError(message: "unknown").description // TODO Improve this message with error details
details.errorMessage = OpenFeatureError
.providerFatalError(message: "unknown")
.description // TODO Improve this message with error details
details.reason = Reason.error.rawValue
return details
case .notReady:
Expand All @@ -86,7 +88,9 @@ extension OpenFeatureClient {
return details
case .error:
details.errorCode = .general
details.errorMessage = OpenFeatureError.generalError(message: "unknown").description // TODO Improve this message with error details
details.errorMessage = OpenFeatureError
.generalError(message: "unknown")
.description // TODO Improve this message with error details
details.reason = Reason.error.rawValue
return details
case .ready:
Expand Down
2 changes: 1 addition & 1 deletion Sources/OpenFeature/Provider/FeatureProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ public protocol FeatureProvider: EventPublisher {

/// Called by OpenFeatureAPI whenever the new Provider is registered
/// This must throw in case of error, using OpenFeature errors whenever possible
/// It is expected that the implementer is slow and blocking (e.g. network), the caller must deal with wrapping this into a background Task if necessary
/// It is expected that the implementer is slow and blocking (e.g. network)
func initialize(initialContext: EvaluationContext?) throws

/// Called by OpenFeatureAPI whenever a new EvaluationContext is set by the application
Expand Down
1 change: 0 additions & 1 deletion Tests/OpenFeatureTests/DeveloperExperienceTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,6 @@ final class DeveloperExperienceTests: XCTestCase {
}
}
) {

let initCompleteExpectation = XCTestExpectation()

let provider = DoSomethingProvider()
Expand Down
9 changes: 4 additions & 5 deletions Tests/OpenFeatureTests/FlagEvaluationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,14 @@ final class FlagEvaluationTests: XCTestCase {
XCTAssertTrue(OpenFeatureAPI.shared === OpenFeatureAPI.shared)
}

func testApiSetsProvider() {
func testApiSetsProvider() async {
let provider = NoOpProvider()
OpenFeatureAPI.shared.setProvider(provider: provider)
await OpenFeatureAPI.shared.setProviderAndWait(provider: provider)
XCTAssertTrue((OpenFeatureAPI.shared.getProvider() as? NoOpProvider) === provider)
}

func testProviderMetadata() {
OpenFeatureAPI.shared.setProvider(provider: DoSomethingProvider())

func testProviderMetadata() async {
await OpenFeatureAPI.shared.setProviderAndWait(provider: DoSomethingProvider())
XCTAssertEqual(OpenFeatureAPI.shared.getProviderMetadata()?.name, DoSomethingProvider.name)
}

Expand Down

0 comments on commit 16499c7

Please sign in to comment.