Skip to content

Commit b88f0a3

Browse files
authored
feat: thread-safe user attribute update tracking and safe reading/updating of evaluations (#116)
1 parent ae98404 commit b88f0a3

19 files changed

+606
-117
lines changed

Bucketeer/Sources/Internal/Evaluation/EvaluationInteractor.swift

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -47,9 +47,11 @@ final class EvaluationInteractorImpl: EvaluationInteractor {
4747

4848
let logger = self.logger
4949
let evaluatedAt = evaluationStorage.evaluatedAt
50-
let userAttributesUpdated = evaluationStorage.userAttributesUpdated
50+
let userAttributesState = evaluationStorage.userAttributesState
51+
let userAttributesUpdated = userAttributesState.isUpdated
5152
let currentEvaluationsId = evaluationStorage.currentEvaluationsId
5253
let featureTag = evaluationStorage.featureTag
54+
5355
apiClient.getEvaluations(
5456
user: user,
5557
userEvaluationsId: currentEvaluationsId,
@@ -62,8 +64,8 @@ final class EvaluationInteractorImpl: EvaluationInteractor {
6264
let newEvaluationsId = response.userEvaluationsId
6365
if currentEvaluationsId == newEvaluationsId {
6466
logger?.debug(message: "Nothing to sync")
65-
// Reset UserAttributesUpdated
66-
self?.evaluationStorage.clearUserAttributesUpdated()
67+
// Clear logic is now encapsulated in `evaluationStorage` via the state snapshot
68+
self?.evaluationStorage.clearUserAttributesUpdated(state: userAttributesState)
6769
completion?(result)
6870
return
6971
}
@@ -97,7 +99,9 @@ final class EvaluationInteractorImpl: EvaluationInteractor {
9799
return
98100
}
99101

100-
self?.evaluationStorage.clearUserAttributesUpdated()
102+
// Clear logic is now encapsulated in `evaluationStorage` via the state snapshot
103+
self?.evaluationStorage.clearUserAttributesUpdated(state: userAttributesState)
104+
101105
if shouldNotifyListener {
102106
// Update listeners should be called on the main thread
103107
// to avoid unintentional lock on Interactor's execution thread.

Bucketeer/Sources/Internal/Evaluation/EvaluationMemCacheDao.swift

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,27 @@ protocol KeyValueCache {
44
func get(key: String) -> DataCacheType?
55
}
66

7-
class InMemoryCache<T:Any> : KeyValueCache {
7+
class InMemoryCache<T: Any>: KeyValueCache {
88
typealias DataCacheType = T
99

10-
private var dict: [String : T] = [:]
10+
private var dict: [String: T] = [:]
11+
// A concurrent queue allows multiple reads to execute in parallel
12+
private let queue = DispatchQueue(label: "io.bucketeer.InMemoryCache", attributes: .concurrent)
1113

1214
func set(key: String, value: T) {
13-
dict[key] = value
15+
// .barrier ensures exclusive access: it waits for pending reads to finish and blocks new ones during the write.
16+
// .sync ensures the update completes before returning, guaranteeing that subsequent reads immediately receive the new value.
17+
queue.sync(flags: .barrier) {
18+
self.dict[key] = value
19+
}
1420
}
1521

1622
func get(key: String) -> T? {
17-
return dict[key]
23+
// .sync waits for this read block to run, then returns its value.
24+
// Reads can run in parallel on the concurrent queue, but this call will wait if a barrier write is in progress or queued ahead of it.
25+
queue.sync {
26+
return dict[key]
27+
}
1828
}
1929
}
2030

Bucketeer/Sources/Internal/Evaluation/EvaluationStorage.swift

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,42 @@ protocol EvaluationStorage {
2020
var featureTag: String { get }
2121
// expected set evaluatedAt from `deleteAllAndInsert` or `update` only
2222
var evaluatedAt: String { get }
23-
var userAttributesUpdated: Bool { get }
23+
24+
// Current version and flag set when `setUserAttributesUpdated()` is called
25+
var userAttributesState: UserAttributesState { get }
2426

2527
func clearCurrentEvaluationsId()
2628
func setFeatureTag(value: String)
2729
func setUserAttributesUpdated()
28-
func clearUserAttributesUpdated()
30+
31+
/// Atomically clear the user-attributes-updated flag if the stored version equals `state.version`.
32+
/// - Parameter state: Snapshot obtained from `userAttributesState` before a network request.
33+
/// - Returns: `true` if the flag was cleared (stored flag was `true` and versions matched); `false` otherwise.
34+
/// - Thread-safety: Implementations MUST perform the compare-and-swap under the storage's internal lock.
35+
/// - Note: `version` is an in-memory, session-only counter; implementations may persist only the
36+
/// boolean flag (e.g., in `UserDefaults`), but the `version` must be treated as transient.
37+
@discardableResult func clearUserAttributesUpdated(state: UserAttributesState) -> Bool
38+
}
39+
40+
/// Snapshot representing the current user-attributes update state for the current app session.
41+
///
42+
/// - `version`: Monotonically increasing counter. Incremented each time `setUserAttributesUpdated()`
43+
/// is called to indicate a new update event. This value is kept in memory only and is not
44+
/// persisted across app restarts.
45+
/// - `isUpdated`: `true` if user attributes have been modified since the last evaluation (i.e., a
46+
/// new update event exists); `false` otherwise. Implementations may persist this flag (for
47+
/// example, in `UserDefaults`) to survive restarts.
48+
///
49+
/// Use this struct as an in-memory snapshot to coordinate whether evaluations must be refreshed
50+
/// due to user attribute changes during a single session. It is not intended to be persisted as a
51+
/// whole across app restarts.
52+
///
53+
/// The ephemeral nature of the version counter is intentional. We employ an Optimistic Locking pattern where the integer value does not need to persist across app restarts:
54+
/// - On restart: The persisted `isUpdated` flag is authoritative. If `true`, an immediate sync is triggered, regardless of the version.
55+
/// - During runtime: The `version` acts as a transaction ID to safely handle race conditions where user attributes change while a background fetch is in progress.
56+
/// - Correctness: The update flag is only cleared via a compare-and-swap check (`currentVersion == capturedVersion`).
57+
/// If the user modifies attributes during a fetch, the `version` increments, the check fails, and the flag remains `true` to schedule a subsequent sync.
58+
struct UserAttributesState {
59+
let version: Int
60+
let isUpdated: Bool
2961
}

Bucketeer/Sources/Internal/Evaluation/EvaluationStorageImpl.swift

Lines changed: 45 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,16 +12,24 @@ final class EvaluationStorageImpl: EvaluationStorage {
1212
return evaluationUserDefaultsDao.evaluatedAt
1313
}
1414

15-
var userAttributesUpdated: Bool {
16-
return evaluationUserDefaultsDao.userAttributesUpdated
15+
var userAttributesState: UserAttributesState {
16+
// read atomically 2 values
17+
return setUserAttributesUpdatedLock.withLock {
18+
return UserAttributesState(
19+
version: userAttributesUpdatedVersion,
20+
isUpdated: evaluationUserDefaultsDao.userAttributesUpdated
21+
)
22+
}
1723
}
1824

1925
private let userId: String
20-
// Expected SQL Dao
2126
private let evaluationSQLDao: EvaluationSQLDao
22-
// Expected in-memory cache Dao
2327
private let evaluationMemCacheDao: EvaluationMemCacheDao
2428
private let evaluationUserDefaultsDao: EvaluationUserDefaultsDao
29+
private let setUserAttributesUpdatedLock = NSLock()
30+
/// Version counter used as an in-memory transaction id for attribute updates.
31+
/// Protected by `setUserAttributesUpdatedLock`.
32+
private var userAttributesUpdatedVersion: Int = 0
2533

2634
init(
2735
userId: String,
@@ -40,6 +48,8 @@ final class EvaluationStorageImpl: EvaluationStorage {
4048
evaluationMemCacheDao.get(key: userId) ?? []
4149
}
4250

51+
/// Deletes all evaluations and inserts new evaluations in storage.
52+
/// - Note: Caller must ensure this is called from the SDK queue. Not thread-safe
4353
func deleteAllAndInsert(
4454
evaluationId: String,
4555
evaluations: [Evaluation],
@@ -55,6 +65,8 @@ final class EvaluationStorageImpl: EvaluationStorage {
5565
evaluationMemCacheDao.set(key: userId, value: evaluations)
5666
}
5767

68+
/// Updates evaluations in storage.
69+
/// - Note: Caller must ensure this is called from the SDK queue. Not thread-safe for concurrent writes.
5870
func update(
5971
evaluationId: String ,
6072
evaluations: [Evaluation],
@@ -87,6 +99,14 @@ final class EvaluationStorageImpl: EvaluationStorage {
8799

88100
// getBy will return the data from the cache to speed up the response time
89101
func getBy(featureId: String) -> Evaluation? {
102+
// evaluationMemCacheDao is thread-safe (uses internal concurrent queue).
103+
// We rely on it without adding extra locks even though this storage layer can be accessed from multiple threads:
104+
// writes and most operations are serialized on the SDK queue, but reads (like getBy) may be invoked from the
105+
// main/UI thread concurrently with background SDK operations.
106+
//
107+
// We access the memory cache directly without waiting for pending database writes.
108+
// If we enforced strict consistency (locking during Disk I/O with SQL), this method would block the calling thread (often the Main Thread), causing UI freezes.
109+
// This behavior prioritizes application responsiveness, accepting momentary data staleness during background updates.
90110
return evaluationMemCacheDao.get(key: userId)?.first { evaluation in
91111
evaluation.featureId == featureId
92112
} ?? nil
@@ -106,10 +126,28 @@ final class EvaluationStorageImpl: EvaluationStorage {
106126
}
107127

108128
func setUserAttributesUpdated() {
109-
evaluationUserDefaultsDao.setUserAttributesUpdated(value: true)
129+
setUserAttributesUpdatedLock.withLock {
130+
// Increment version on every update
131+
userAttributesUpdatedVersion += 1
132+
evaluationUserDefaultsDao.setUserAttributesUpdated(value: true)
133+
}
110134
}
111135

112-
func clearUserAttributesUpdated() {
113-
evaluationUserDefaultsDao.setUserAttributesUpdated(value: false)
136+
// Called from SDK queue (fetch callback)
137+
@discardableResult func clearUserAttributesUpdated(state: UserAttributesState) -> Bool {
138+
guard state.isUpdated else {
139+
// No-op if flag is already false
140+
return false
141+
}
142+
return setUserAttributesUpdatedLock.withLock {
143+
// Only clear if the version matches what we captured at the start of the request.
144+
// If userAttributesUpdatedVersion > version, it means a new update happened
145+
// while the request was in-flight, so we MUST NOT clear the flag.
146+
if userAttributesUpdatedVersion == state.version {
147+
evaluationUserDefaultsDao.setUserAttributesUpdated(value: false)
148+
return true
149+
}
150+
return false
151+
}
114152
}
115153
}

Bucketeer/Sources/Internal/Evaluation/EvaluationUserDefaultDaoImpl.swift

Lines changed: 9 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,7 @@
11
import Foundation
22

33
class EvaluationUserDefaultDaoImpl: EvaluationUserDefaultsDao {
4-
private static let userEvaluationsIdKey = "bucketeer_user_evaluations_id"
5-
private static let featureTagKey = "bucketeer_feature_tag"
6-
private static let evaluatedAtKey = "bucketeer_evaluated_at"
7-
private static let userAttributesUpdatedKey = "bucketeer_user_attributes_updated"
4+
85
private let defs: Defaults
96

107
init(defaults: Defaults) {
@@ -13,34 +10,34 @@ class EvaluationUserDefaultDaoImpl: EvaluationUserDefaultsDao {
1310

1411
var userAttributesUpdated: Bool {
1512
get {
16-
return defs.bool(forKey: Self.userAttributesUpdatedKey)
13+
return defs.bool(forKey: EvaluationUserDefaultsKey.userAttributesUpdated.rawValue)
1714
}
1815
set {
19-
defs.set(newValue, forKey: Self.userAttributesUpdatedKey)
16+
defs.set(newValue, forKey: EvaluationUserDefaultsKey.userAttributesUpdated.rawValue)
2017
}
2118
}
2219
var currentEvaluationsId: String {
2320
get {
24-
return defs.string(forKey: Self.userEvaluationsIdKey) ?? ""
21+
return defs.string(forKey: EvaluationUserDefaultsKey.userEvaluationsId.rawValue) ?? ""
2522
}
2623
set {
27-
defs.set(newValue, forKey: Self.userEvaluationsIdKey)
24+
defs.set(newValue, forKey: EvaluationUserDefaultsKey.userEvaluationsId.rawValue)
2825
}
2926
}
3027
var featureTag: String {
3128
get {
32-
return defs.string(forKey: Self.featureTagKey) ?? ""
29+
return defs.string(forKey: EvaluationUserDefaultsKey.featureTag.rawValue) ?? ""
3330
}
3431
set {
35-
defs.set(newValue, forKey: Self.featureTagKey)
32+
defs.set(newValue, forKey: EvaluationUserDefaultsKey.featureTag.rawValue)
3633
}
3734
}
3835
var evaluatedAt: String {
3936
get {
40-
return defs.string(forKey: Self.evaluatedAtKey) ?? "0"
37+
return defs.string(forKey: EvaluationUserDefaultsKey.evaluatedAt.rawValue) ?? "0"
4138
}
4239
set {
43-
defs.set(newValue, forKey: Self.evaluatedAtKey)
40+
defs.set(newValue, forKey: EvaluationUserDefaultsKey.evaluatedAt.rawValue)
4441
}
4542
}
4643

Bucketeer/Sources/Internal/Evaluation/EvaluationUserDefaultsDao.swift

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,10 @@ protocol EvaluationUserDefaultsDao {
1111
func setEvaluatedAt(value: String)
1212
func setUserAttributesUpdated(value: Bool)
1313
}
14+
15+
enum EvaluationUserDefaultsKey: String {
16+
case userEvaluationsId = "bucketeer_user_evaluations_id"
17+
case featureTag = "bucketeer_feature_tag"
18+
case evaluatedAt = "bucketeer_evaluated_at"
19+
case userAttributesUpdated = "bucketeer_user_attributes_updated"
20+
}

Bucketeer/Sources/Internal/Utils/Defaults.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ protocol Defaults {
44
func string(forKey defaultName: String) -> String?
55
func bool(forKey defaultName: String) -> Bool
66
func set(_ value: Any?, forKey defaultName: String)
7+
func removeObject(forKey defaultName: String)
78
}
89

910
extension UserDefaults: Defaults {}

BucketeerTests/BKTClientTests.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,7 @@ final class BKTClientTests: XCTestCase {
148148
// mark the next request ready
149149
requestCount = 2
150150
XCTAssertEqual(
151-
dataModule.evaluationStorage.userAttributesUpdated,
151+
dataModule.evaluationStorage.userAttributesState.isUpdated,
152152
true, "userAttributesUpdated should be true")
153153
client.fetchEvaluations(timeoutMillis: nil) { error in
154154
XCTAssertEqual(error, nil)

0 commit comments

Comments
 (0)