Skip to content

Commit 9f72ec9

Browse files
Keep a record of the last fetched flags and only emit a new value if it's changed (#77)
* Keep a record of the last fetched flags and only emit a new value if it's changed * Improve testability and add a unit test for the new equality checks * Update FlagsmithClient/Classes/Flagsmith.swift Co-authored-by: Matthew Elwell <mjelwell89@gmail.com> * Used an actor for the yield count to make it more thread safe on Swift 5. Hopefully this fixes the build issues. * And also the updated tests * Reduced the iterations on the concurrent request test as it wasn't completing within the 10 second time limit and also removed a warning. --------- Co-authored-by: Matthew Elwell <mjelwell89@gmail.com>
1 parent 99e4ca6 commit 9f72ec9

4 files changed

Lines changed: 100 additions & 7 deletions

File tree

FlagsmithClient/Classes/Flag.swift

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import Foundation
1010
/**
1111
A Flag represents a feature flag on the server.
1212
*/
13-
public struct Flag: Codable, Sendable {
13+
public struct Flag: Codable, Sendable, Equatable {
1414
enum CodingKeys: String, CodingKey {
1515
case feature
1616
case value = "feature_state_value"
@@ -70,4 +70,10 @@ public struct Flag: Codable, Sendable {
7070
try container.encode(value, forKey: .value)
7171
try container.encode(enabled, forKey: .enabled)
7272
}
73+
74+
public static func == (lhs: Flag, rhs: Flag) -> Bool {
75+
return lhs.feature.name == rhs.feature.name &&
76+
lhs.value == rhs.value &&
77+
lhs.enabled == rhs.enabled
78+
}
7379
}

FlagsmithClient/Classes/Flagsmith.swift

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ public final class Flagsmith: @unchecked Sendable {
2626

2727
// The last identity used for fetching flags
2828
private var lastUsedIdentity: String?
29+
// The last result from fetching flags
30+
internal var lastFlags: [Flag]?
2931

3032
var anyFlagStreamContinuation: Any? // AsyncStream<[Flag]>.Continuation? for iOS 13+
3133

@@ -364,10 +366,8 @@ public final class Flagsmith: @unchecked Sendable {
364366
switch result {
365367
case let .failure(error):
366368
print("Flagsmith - Error getting flags in SSE stream: \(error.localizedDescription)")
367-
368-
case .success:
369-
// On success the flastream is updated automatically in the API call
370-
print("Flagsmith - Flags updated from SSE stream.")
369+
case .success(_):
370+
break
371371
}
372372
}
373373
}
@@ -380,13 +380,18 @@ public final class Flagsmith: @unchecked Sendable {
380380
func updateFlagStreamAndLastUpdatedAt(_ flags: [Flag]) {
381381
// Update the flag stream
382382
if #available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 7.0, *) {
383-
flagStreamContinuation?.yield(flags)
383+
if (flags != lastFlags) {
384+
flagStreamContinuation?.yield(flags)
385+
}
384386
}
385387

386388
// Update the last updated time if the API is giving us newer data
387389
if let apiManagerUpdatedAt = apiManager.lastUpdatedAt, apiManagerUpdatedAt > lastUpdatedAt {
388390
lastUpdatedAt = apiManagerUpdatedAt
389391
}
392+
393+
// Save the last set of flags we got so that we have something to compare against and only publish changes
394+
lastFlags = flags
390395
}
391396
}
392397

FlagsmithClient/Tests/APIManagerTests.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ final class APIManagerTests: FlagsmithClientTestCase {
6363
let concurrentQueue = DispatchQueue(label: "concurrentQueue", attributes: .concurrent)
6464

6565
var expectations: [XCTestExpectation] = []
66-
let iterations = 500
66+
let iterations = 100
6767

6868
for concurrentIteration in 1 ... iterations {
6969
let expectation = XCTestExpectation(description: "Multiple threads can access the APIManager \(concurrentIteration)")

FlagsmithClient/Tests/SSEManagerTests.swift

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,4 +115,86 @@ class SSEManagerTests: FlagsmithClientTestCase {
115115

116116
wait(for: [requestFinished], timeout: 1.0)
117117
}
118+
119+
func testFlagStreamYieldsOnlyOnDifferentFlags() {
120+
guard #available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 7.0, *) else {
121+
XCTFail("AsyncStream is not available on this platform.")
122+
return
123+
}
124+
125+
let flagsmith = Flagsmith.shared
126+
var continuation: AsyncStream<[Flag]>.Continuation?
127+
let stream = AsyncStream<[Flag]> { cont in
128+
continuation = cont
129+
}
130+
flagsmith.anyFlagStreamContinuation = continuation
131+
132+
// Reset lastFlags to ensure a clean state for the test
133+
flagsmith.lastFlags = nil
134+
135+
let flag1 = Flag(featureName: "test_feature_1", value: .string("value1"), enabled: true, featureType: "STANDARD")
136+
let flag2 = Flag(featureName: "test_feature_2", value: .int(123), enabled: false, featureType: "STANDARD")
137+
let flags1 = [flag1, flag2]
138+
139+
let flag3 = Flag(featureName: "test_feature_3", value: .bool(true), enabled: true, featureType: "STANDARD")
140+
let flags2 = [flag1, flag3] // Different set of flags
141+
142+
let firstYieldExpectation = expectation(description: "First flags yielded")
143+
let noYieldExpectation = expectation(description: "No yield on same flags")
144+
noYieldExpectation.isInverted = true
145+
let secondYieldExpectation = expectation(description: "Second flags yielded")
146+
147+
// Use an actor to make the counter thread-safe for Swift 5 compatibility
148+
actor YieldCounter {
149+
private var count = 0
150+
151+
func increment() -> Int {
152+
count += 1
153+
return count
154+
}
155+
}
156+
157+
let yieldCounter = YieldCounter()
158+
let taskCompletionExpectation = expectation(description: "Task completed")
159+
160+
let _ = Task {
161+
for await flags in stream {
162+
let currentCount = await yieldCounter.increment()
163+
switch currentCount {
164+
case 1:
165+
XCTAssertEqual(flags, flags1)
166+
firstYieldExpectation.fulfill()
167+
case 2:
168+
XCTAssertEqual(flags, flags2)
169+
secondYieldExpectation.fulfill()
170+
default:
171+
XCTFail("Unexpected yield from stream")
172+
}
173+
}
174+
// Signal that the task has finished processing all stream items
175+
taskCompletionExpectation.fulfill()
176+
}
177+
178+
// 1. Call with new flags (should yield)
179+
flagsmith.updateFlagStreamAndLastUpdatedAt(flags1)
180+
wait(for: [firstYieldExpectation], timeout: 1.0)
181+
182+
// 2. Call with same flags (should NOT yield)
183+
flagsmith.updateFlagStreamAndLastUpdatedAt(flags1)
184+
wait(for: [noYieldExpectation], timeout: 0.1) // Short timeout to ensure no yield
185+
186+
// 3. Call with different flags (should yield)
187+
flagsmith.updateFlagStreamAndLastUpdatedAt(flags2)
188+
wait(for: [secondYieldExpectation], timeout: 1.0)
189+
190+
// Clean up: stop the stream and wait for task to complete
191+
continuation?.finish()
192+
193+
// Wait for the async task to finish processing all items before continuing
194+
wait(for: [taskCompletionExpectation], timeout: 1.0)
195+
196+
// Reset Flagsmith state to avoid interference with other tests
197+
flagsmith.anyFlagStreamContinuation = nil
198+
flagsmith.lastFlags = nil
199+
}
118200
}

0 commit comments

Comments
 (0)