Skip to content

Commit 65cb295

Browse files
feat: Improves EvaluationContext thread safety (#69)
<!-- Please use this template for your pull request. --> <!-- Please use the sections that you need and delete other sections --> ## This PR Creates a deep copy of the evaluation context before passing it into the provider. While the original evaluation context class could be mutated by the hosting application, the copy will not be affected, ensuring higher thread safety guarantees for the consuming provider. Mutable classes are made thread safe as well. ### Related Issues Race conditions within `onContextSet` implementation have been reported for some Swift Providers. ### How to test Unit tests provided for the deep copy, but difficult to test the race condition possibility. --------- Signed-off-by: Fabrizio Demaria <[email protected]>
1 parent 3a5af18 commit 65cb295

File tree

7 files changed

+192
-21
lines changed

7 files changed

+192
-21
lines changed

Package.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ let package = Package(
1414
products: [
1515
.library(
1616
name: "OpenFeature",
17-
targets: ["OpenFeature"])
17+
targets: ["OpenFeature"]),
1818
],
1919
dependencies: [],
2020
targets: [

Sources/OpenFeature/EvaluationContext.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,6 @@ import Foundation
33
/// Container for arbitrary contextual data that can be used as a basis for dynamic evaluation.
44
public protocol EvaluationContext: Structure {
55
func getTargetingKey() -> String
6-
6+
func deepCopy() -> EvaluationContext
77
func setTargetingKey(targetingKey: String)
88
}
Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import Foundation
22

3-
/// The ``MutableContext`` is an ``EvaluationContext`` implementation which is not threadsafe, and whose attributes can
3+
/// The ``MutableContext`` is an ``EvaluationContext`` implementation which is threadsafe, and whose attributes can
44
/// be modified after instantiation.
55
public class MutableContext: EvaluationContext {
6+
private let queue = DispatchQueue(label: "com.openfeature.mutablecontext.queue", qos: .userInitiated)
67
private var targetingKey: String
78
private var structure: MutableStructure
89

@@ -15,35 +16,55 @@ public class MutableContext: EvaluationContext {
1516
self.init(structure: MutableStructure(attributes: attributes))
1617
}
1718

19+
public func deepCopy() -> EvaluationContext {
20+
return queue.sync {
21+
MutableContext(targetingKey: targetingKey, structure: structure.deepCopy())
22+
}
23+
}
24+
1825
public func getTargetingKey() -> String {
19-
return self.targetingKey
26+
return queue.sync {
27+
self.targetingKey
28+
}
2029
}
2130

2231
public func setTargetingKey(targetingKey: String) {
23-
self.targetingKey = targetingKey
32+
queue.sync {
33+
self.targetingKey = targetingKey
34+
}
2435
}
2536

2637
public func keySet() -> Set<String> {
27-
return structure.keySet()
38+
return queue.sync {
39+
structure.keySet()
40+
}
2841
}
2942

3043
public func getValue(key: String) -> Value? {
31-
return structure.getValue(key: key)
44+
return queue.sync {
45+
structure.getValue(key: key)
46+
}
3247
}
3348

3449
public func asMap() -> [String: Value] {
35-
return structure.asMap()
50+
return queue.sync {
51+
structure.asMap()
52+
}
3653
}
3754

3855
public func asObjectMap() -> [String: AnyHashable?] {
39-
return structure.asObjectMap()
56+
return queue.sync {
57+
structure.asObjectMap()
58+
}
4059
}
4160
}
4261

4362
extension MutableContext {
4463
@discardableResult
4564
public func add(key: String, value: Value) -> MutableContext {
46-
self.structure.add(key: key, value: value)
65+
queue.sync {
66+
self.structure.add(key: key, value: value)
67+
}
4768
return self
4869
}
4970
}

Sources/OpenFeature/MutableStructure.swift

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,43 @@
11
import Foundation
22

3-
/// The ``MutableStructure`` is a ``Structure`` implementation which is not threadsafe, and whose attributes can
3+
/// The ``MutableStructure`` is a ``Structure`` implementation which is threadsafe, and whose attributes can
44
/// be modified after instantiation.
55
public class MutableStructure: Structure {
6+
private let queue = DispatchQueue(label: "com.openfeature.mutablestructure.queue", qos: .userInitiated)
67
private var attributes: [String: Value]
78

89
public init(attributes: [String: Value] = [:]) {
910
self.attributes = attributes
1011
}
1112

1213
public func keySet() -> Set<String> {
13-
return Set(attributes.keys)
14+
return queue.sync {
15+
Set(attributes.keys)
16+
}
1417
}
1518

1619
public func getValue(key: String) -> Value? {
17-
return attributes[key]
20+
return queue.sync {
21+
attributes[key]
22+
}
1823
}
1924

2025
public func asMap() -> [String: Value] {
21-
return attributes
26+
return queue.sync {
27+
attributes
28+
}
2229
}
2330

2431
public func asObjectMap() -> [String: AnyHashable?] {
25-
return attributes.mapValues(convertValue)
32+
return queue.sync {
33+
attributes.mapValues(convertValue)
34+
}
35+
}
36+
37+
public func deepCopy() -> MutableStructure {
38+
return queue.sync {
39+
MutableStructure(attributes: attributes)
40+
}
2641
}
2742
}
2843

@@ -58,7 +73,9 @@ extension MutableStructure {
5873
extension MutableStructure {
5974
@discardableResult
6075
public func add(key: String, value: Value) -> MutableStructure {
61-
attributes[key] = value
76+
queue.sync {
77+
attributes[key] = value
78+
}
6279
return self
6380
}
6481
}

Sources/OpenFeature/OpenFeatureAPI.swift

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -156,11 +156,11 @@ public class OpenFeatureAPI {
156156
self.providerSubject.send(provider)
157157

158158
if let initialContext = initialContext {
159-
self.evaluationContext = initialContext
159+
self.evaluationContext = initialContext.deepCopy()
160160
}
161161

162162
do {
163-
try await provider.initialize(initialContext: initialContext)
163+
try await provider.initialize(initialContext: initialContext?.deepCopy())
164164
self.providerStatus = .ready
165165
self.eventHandler.send(.ready)
166166
} catch {
@@ -177,11 +177,14 @@ public class OpenFeatureAPI {
177177

178178
private func updateContext(evaluationContext: EvaluationContext) async {
179179
do {
180-
let oldContext = self.evaluationContext
181-
self.evaluationContext = evaluationContext
180+
let oldContext = self.evaluationContext?.deepCopy()
181+
self.evaluationContext = evaluationContext.deepCopy()
182182
self.providerStatus = .reconciling
183183
eventHandler.send(.reconciling)
184-
try await self.providerSubject.value?.onContextSet(oldContext: oldContext, newContext: evaluationContext)
184+
try await self.providerSubject.value?.onContextSet(
185+
oldContext: oldContext,
186+
newContext: evaluationContext.deepCopy()
187+
)
185188
self.providerStatus = .ready
186189
eventHandler.send(.contextChanged)
187190
} catch {

Sources/OpenFeature/Value.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import Foundation
33
/// Values serve as a generic return type for structure data from providers.
44
/// Providers may deal in JSON, protobuf, XML or some other data-interchange format.
55
/// This intermediate representation provides a good medium of exchange.
6+
/// All the enum types must be value types, as Value is expected to support deep copies.
67
public enum Value: Equatable, Codable {
78
case boolean(Bool)
89
case string(String)

Tests/OpenFeatureTests/EvalContextTests.swift

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,4 +136,133 @@ final class EvalContextTests: XCTestCase {
136136

137137
XCTAssertEqual(ctx.asObjectMap(), expected)
138138
}
139+
140+
func testContextDeepCopyCreatesIndependentCopy() {
141+
// Create original context with various data types
142+
let originalContext = MutableContext(targetingKey: "original-key")
143+
originalContext.add(key: "string", value: .string("original-value"))
144+
originalContext.add(key: "integer", value: .integer(42))
145+
originalContext.add(key: "boolean", value: .boolean(true))
146+
originalContext.add(key: "list", value: .list([.string("item1"), .integer(100)]))
147+
originalContext.add(key: "structure", value: .structure([
148+
"nested-string": .string("nested-value"),
149+
"nested-int": .integer(200),
150+
]))
151+
152+
guard let copiedContext = originalContext.deepCopy() as? MutableContext else {
153+
XCTFail("Failed to cast to MutableContext")
154+
return
155+
}
156+
157+
XCTAssertEqual(copiedContext.getTargetingKey(), "original-key")
158+
XCTAssertEqual(copiedContext.getValue(key: "string")?.asString(), "original-value")
159+
XCTAssertEqual(copiedContext.getValue(key: "integer")?.asInteger(), 42)
160+
XCTAssertEqual(copiedContext.getValue(key: "boolean")?.asBoolean(), true)
161+
XCTAssertEqual(copiedContext.getValue(key: "list")?.asList()?[0].asString(), "item1")
162+
XCTAssertEqual(copiedContext.getValue(key: "list")?.asList()?[1].asInteger(), 100)
163+
XCTAssertEqual(
164+
copiedContext.getValue(key: "structure")?.asStructure()?["nested-string"]?.asString(),
165+
"nested-value"
166+
)
167+
XCTAssertEqual(copiedContext.getValue(key: "structure")?.asStructure()?["nested-int"]?.asInteger(), 200)
168+
169+
originalContext.setTargetingKey(targetingKey: "modified-key")
170+
originalContext.add(key: "string", value: .string("modified-value"))
171+
originalContext.add(key: "new-key", value: .string("new-value"))
172+
173+
XCTAssertEqual(copiedContext.getTargetingKey(), "original-key")
174+
XCTAssertEqual(copiedContext.getValue(key: "string")?.asString(), "original-value")
175+
XCTAssertNil(copiedContext.getValue(key: "new-key"))
176+
XCTAssertEqual(originalContext.getTargetingKey(), "modified-key")
177+
XCTAssertEqual(originalContext.getValue(key: "string")?.asString(), "modified-value")
178+
XCTAssertEqual(originalContext.getValue(key: "new-key")?.asString(), "new-value")
179+
}
180+
181+
func testContextDeepCopyWithEmptyContext() {
182+
let emptyContext = MutableContext()
183+
guard let copiedContext = emptyContext.deepCopy() as? MutableContext else {
184+
XCTFail("Failed to cast to MutableContext")
185+
return
186+
}
187+
188+
XCTAssertEqual(emptyContext.getTargetingKey(), "")
189+
XCTAssertEqual(copiedContext.getTargetingKey(), "")
190+
XCTAssertTrue(emptyContext.keySet().isEmpty)
191+
XCTAssertTrue(copiedContext.keySet().isEmpty)
192+
193+
emptyContext.setTargetingKey(targetingKey: "test")
194+
emptyContext.add(key: "key", value: .string("value"))
195+
196+
XCTAssertEqual(copiedContext.getTargetingKey(), "")
197+
XCTAssertTrue(copiedContext.keySet().isEmpty)
198+
}
199+
200+
func testContextDeepCopyPreservesAllValueTypes() {
201+
let date = Date()
202+
let originalContext = MutableContext(targetingKey: "test-key")
203+
originalContext.add(key: "null", value: .null)
204+
originalContext.add(key: "string", value: .string("test-string"))
205+
originalContext.add(key: "boolean", value: .boolean(false))
206+
originalContext.add(key: "integer", value: .integer(12345))
207+
originalContext.add(key: "double", value: .double(3.14159))
208+
originalContext.add(key: "date", value: .date(date))
209+
originalContext.add(key: "list", value: .list([.string("list-item"), .integer(999)]))
210+
originalContext.add(key: "structure", value: .structure([
211+
"struct-key": .string("struct-value"),
212+
"struct-number": .integer(777),
213+
]))
214+
215+
guard let copiedContext = originalContext.deepCopy() as? MutableContext else {
216+
XCTFail("Failed to cast to MutableContext")
217+
return
218+
}
219+
220+
XCTAssertTrue(copiedContext.getValue(key: "null")?.isNull() ?? false)
221+
XCTAssertEqual(copiedContext.getValue(key: "string")?.asString(), "test-string")
222+
XCTAssertEqual(copiedContext.getValue(key: "boolean")?.asBoolean(), false)
223+
XCTAssertEqual(copiedContext.getValue(key: "integer")?.asInteger(), 12345)
224+
XCTAssertEqual(copiedContext.getValue(key: "double")?.asDouble(), 3.14159)
225+
XCTAssertEqual(copiedContext.getValue(key: "date")?.asDate(), date)
226+
XCTAssertEqual(copiedContext.getValue(key: "list")?.asList()?[0].asString(), "list-item")
227+
XCTAssertEqual(copiedContext.getValue(key: "list")?.asList()?[1].asInteger(), 999)
228+
XCTAssertEqual(
229+
copiedContext.getValue(key: "structure")?.asStructure()?["struct-key"]?.asString(),
230+
"struct-value"
231+
)
232+
XCTAssertEqual(copiedContext.getValue(key: "structure")?.asStructure()?["struct-number"]?.asInteger(), 777)
233+
}
234+
235+
func testContextDeepCopyIsThreadSafe() {
236+
let context = MutableContext(targetingKey: "initial-key")
237+
context.add(key: "initial", value: .string("initial-value"))
238+
239+
let expectation = XCTestExpectation(description: "Concurrent deep copy operations")
240+
let concurrentQueue = DispatchQueue(label: "test.concurrent", attributes: .concurrent)
241+
let group = DispatchGroup()
242+
243+
// Perform multiple concurrent operations
244+
for i in 0..<100 {
245+
group.enter()
246+
concurrentQueue.async {
247+
// Modify the context
248+
context.setTargetingKey(targetingKey: "modified-\(i)")
249+
context.add(key: "key-\(i)", value: .integer(Int64(i)))
250+
251+
// Perform deep copy
252+
let copiedContext = context.deepCopy()
253+
254+
// Verify the copy is independent
255+
XCTAssertNotEqual(copiedContext.getTargetingKey(), "initial-key")
256+
XCTAssertNotNil(copiedContext.getValue(key: "initial"))
257+
258+
group.leave()
259+
}
260+
}
261+
262+
group.notify(queue: .main) {
263+
expectation.fulfill()
264+
}
265+
266+
wait(for: [expectation], timeout: 5.0)
267+
}
139268
}

0 commit comments

Comments
 (0)