Skip to content

Commit 4d5567b

Browse files
feat: Update cached config logic: 30-day TTL and per-user caching (#226)
* Initial plan for issue * Implement per-user config caching and update default TTL to 30 days Co-authored-by: jonathannorris <[email protected]> * Pull default TTL value into a constant Co-authored-by: jonathannorris <[email protected]> * feat: update cache migration logic, update tests * feat: restructure Cache.swift, remove saving un-used user and config * feat: cleanup creating CacheService() --------- Co-authored-by: copilot-swe-agent[bot] <[email protected]> Co-authored-by: jonathannorris <[email protected]> Co-authored-by: Jonathan Norris <[email protected]>
1 parent 7d87a92 commit 4d5567b

File tree

8 files changed

+466
-120
lines changed

8 files changed

+466
-120
lines changed

DevCycle/DevCycleClient.swift

Lines changed: 26 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,11 @@ public class DevCycleClient {
6565
return
6666
}
6767

68+
// Only create new cache service if configCacheTTL is specified
69+
if let configCacheTTL = self.options?.configCacheTTL {
70+
self.cacheService = CacheService(configCacheTTL: configCacheTTL)
71+
}
72+
6873
self.config = DVCConfig(sdkKey: sdkKey, user: user)
6974

7075
let service = DevCycleService(
@@ -142,10 +147,8 @@ public class DevCycleClient {
142147
self.service = service
143148

144149
var cachedConfig: UserConfig?
145-
if let configCacheTTL = options?.configCacheTTL,
146-
let disableConfigCache = options?.disableConfigCache, !disableConfigCache
147-
{
148-
cachedConfig = cacheService.getConfig(user: user, ttlMs: configCacheTTL)
150+
if let disableConfigCache = options?.disableConfigCache, !disableConfigCache {
151+
cachedConfig = cacheService.getConfig(user: user)
149152
}
150153

151154
if cachedConfig != nil {
@@ -160,30 +163,30 @@ public class DevCycleClient {
160163
guard let self = self else { return }
161164
if let error = error {
162165
Log.error("Error getting config: \(error)", tags: ["setup"])
163-
self.cache = self.cacheService.load()
166+
self.cache = self.cacheService.load(user: user)
164167
} else {
165168
if let config = config {
166169
Log.debug("Config: \(config)", tags: ["setup"])
167170
}
168171
self.config?.userConfig = config
169172
self.isConfigCached = false
173+
}
170174

171-
self.cacheUser(user: user)
172-
173-
if self.checkIfEdgeDBEnabled(config: config!, enableEdgeDB: self.enableEdgeDB) {
174-
if !(user.isAnonymous ?? false) {
175-
self.service?.saveEntity(
176-
user: user,
177-
completion: { data, response, error in
178-
if error != nil {
179-
Log.error(
180-
"Error saving user entity for \(user). Error: \(String(describing: error))"
181-
)
182-
} else {
183-
Log.info("Saved user entity")
184-
}
185-
})
186-
}
175+
if let config = config,
176+
self.checkIfEdgeDBEnabled(config: config, enableEdgeDB: self.enableEdgeDB)
177+
{
178+
if !(user.isAnonymous ?? false) {
179+
self.service?.saveEntity(
180+
user: user,
181+
completion: { data, response, error in
182+
if error != nil {
183+
Log.error(
184+
"Error saving user entity for \(user). Error: \(String(describing: error))"
185+
)
186+
} else {
187+
Log.info("Saved user entity")
188+
}
189+
})
187190
}
188191
}
189192

@@ -243,10 +246,6 @@ public class DevCycleClient {
243246
}
244247
}
245248

246-
private func cacheUser(user: DevCycleUser) {
247-
self.cacheService.save(user: user)
248-
}
249-
250249
private func setupSSEConnection() {
251250
if let disableRealtimeUpdates = self.options?.disableRealtimeUpdates, disableRealtimeUpdates
252251
{
@@ -425,7 +424,7 @@ public class DevCycleClient {
425424
guard let self = self else { return }
426425
if let error = error {
427426
Log.error("Error getting config: \(error)", tags: ["identify"])
428-
self.cache = self.cacheService.load()
427+
self.cache = self.cacheService.load(user: updateUser)
429428
} else {
430429
if let config = config {
431430
Log.debug("Config: \(config)", tags: ["identify"])
@@ -434,7 +433,6 @@ public class DevCycleClient {
434433
self.isConfigCached = false
435434
}
436435
self.user = user
437-
self.cacheUser(user: user)
438436
callback?(error, config?.variables)
439437
})
440438
}
@@ -457,7 +455,7 @@ public class DevCycleClient {
457455
}
458456

459457
public func resetUser(callback: IdentifyCompletedHandler? = nil) throws {
460-
self.cache = cacheService.load()
458+
self.cache = cacheService.load(user: self.user!)
461459
self.flushEvents()
462460

463461
let cachedAnonUserId = self.cacheService.getAnonUserId()
@@ -484,7 +482,6 @@ public class DevCycleClient {
484482
self.config?.userConfig = config
485483
self.isConfigCached = false
486484
self.user = anonUser
487-
self.cacheUser(user: anonUser)
488485
callback?(error, config?.variables)
489486
})
490487
}

DevCycle/Models/Cache.swift

Lines changed: 122 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -7,139 +7,192 @@
77
import Foundation
88

99
protocol CacheServiceProtocol {
10-
func load() -> Cache
11-
func save(user: DevCycleUser)
10+
func load(user: DevCycleUser) -> Cache
1211
func setAnonUserId(anonUserId: String)
1312
func getAnonUserId() -> String?
1413
func clearAnonUserId()
15-
func saveConfig(user: DevCycleUser, fetchDate: Int, configToSave: Data?)
16-
func getConfig(user: DevCycleUser, ttlMs: Int) -> UserConfig?
14+
func saveConfig(user: DevCycleUser, configToSave: Data?)
15+
func getConfig(user: DevCycleUser) -> UserConfig?
1716
func getOrCreateAnonUserId() -> String
17+
func migrateLegacyCache()
1818
}
1919

2020
struct Cache {
2121
var config: UserConfig?
22-
var user: DevCycleUser?
2322
var anonUserId: String?
2423
}
2524

2625
class CacheService: CacheServiceProtocol {
2726
struct CacheKeys {
28-
static let user = "user"
29-
static let config = "config"
3027
static let anonUserId = "ANONYMOUS_USER_ID"
3128
static let identifiedConfigKey = "IDENTIFIED_CONFIG"
3229
static let anonymousConfigKey = "ANONYMOUS_CONFIG"
30+
static let userIdSuffix = ".USER_ID"
31+
static let expiryDateSuffix = ".EXPIRY_DATE"
32+
33+
// Legacy keys for cleanup
34+
static let legacyUser = "user"
35+
static let legacyConfig = "config"
36+
static let legacyFetchDateSuffix = ".FETCH_DATE"
3337
}
3438

3539
private let defaults: UserDefaults = UserDefaults.standard
40+
private let configCacheTTL: Int
3641

37-
func load() -> Cache {
38-
var userConfig: UserConfig?
39-
var dvcUser: DevCycleUser?
40-
if let data = defaults.object(forKey: CacheKeys.config) as? Data,
41-
let dictionary = try? JSONSerialization.jsonObject(
42-
with: data, options: .fragmentsAllowed) as? [String: Any],
43-
let config = try? UserConfig(from: dictionary)
44-
{
45-
userConfig = config
46-
}
47-
if let data = defaults.object(forKey: CacheKeys.user) as? Data {
48-
dvcUser = try? JSONDecoder().decode(DevCycleUser.self, from: data)
49-
}
50-
let anonUserId = self.getAnonUserId()
51-
52-
return Cache(config: userConfig, user: dvcUser, anonUserId: anonUserId)
42+
init(configCacheTTL: Int = DEFAULT_CONFIG_CACHE_TTL) {
43+
self.configCacheTTL = configCacheTTL
5344
}
5445

55-
func save(user: DevCycleUser) {
56-
if let data = try? JSONEncoder().encode(user) {
57-
defaults.set(data, forKey: CacheKeys.user)
58-
}
46+
func load(user: DevCycleUser) -> Cache {
47+
migrateLegacyCache()
48+
49+
return Cache(config: getConfig(user: user), anonUserId: getAnonUserId())
5950
}
6051

6152
func setAnonUserId(anonUserId: String) {
62-
self.setString(key: CacheKeys.anonUserId, value: anonUserId)
53+
defaults.set(anonUserId, forKey: CacheKeys.anonUserId)
6354
}
6455

6556
func getAnonUserId() -> String? {
66-
return self.getString(key: CacheKeys.anonUserId)
57+
return defaults.string(forKey: CacheKeys.anonUserId)
6758
}
6859

6960
func clearAnonUserId() {
70-
self.remove(key: CacheKeys.anonUserId)
61+
defaults.removeObject(forKey: CacheKeys.anonUserId)
7162
}
7263

73-
func saveConfig(user: DevCycleUser, fetchDate: Int, configToSave: Data?) {
64+
func saveConfig(user: DevCycleUser, configToSave: Data?) {
7465
let key = getConfigKeyPrefix(user: user)
7566
defaults.set(configToSave, forKey: key)
76-
if let data = user.userId {
77-
self.setString(key: "\(key).USER_ID", value: data)
78-
}
79-
self.setInt(key: "\(key).FETCH_DATE", value: fetchDate)
67+
68+
let expiryDate = currentTimeMs() + configCacheTTL
69+
defaults.set(expiryDate, forKey: "\(key)\(CacheKeys.expiryDateSuffix)")
8070
}
8171

82-
func getConfig(user: DevCycleUser, ttlMs: Int) -> UserConfig? {
72+
func getConfig(user: DevCycleUser) -> UserConfig? {
8373
let key = getConfigKeyPrefix(user: user)
84-
var config: UserConfig?
8574

86-
let savedUserId = self.getString(key: "\(key).USER_ID")
87-
let savedFetchDate = self.getInt(key: "\(key).FETCH_DATE")
88-
89-
if let userId = user.userId, userId != savedUserId {
90-
Log.debug("Skipping cached config: user ID does not match")
91-
return nil
92-
}
93-
94-
let oldestValidDateMs = Int(Date().timeIntervalSince1970) - ttlMs
95-
if let savedFetchDate = savedFetchDate, savedFetchDate < oldestValidDateMs {
96-
Log.debug("Skipping cached config: last fetched date is too old")
75+
// Check if cache has expired
76+
if let savedExpiryDate = getIntValue(forKey: "\(key)\(CacheKeys.expiryDateSuffix)"),
77+
currentTimeMs() > savedExpiryDate
78+
{
79+
Log.debug("Skipping cached config: config has expired")
80+
cleanupCacheEntry(key: key)
9781
return nil
9882
}
9983

100-
if let data = defaults.object(forKey: key) as? Data,
84+
// Try to load and parse cached config
85+
guard let data = defaults.object(forKey: key) as? Data,
10186
let dictionary = try? JSONSerialization.jsonObject(
102-
with: data, options: .fragmentsAllowed) as? [String: Any]
103-
{
104-
config = try? UserConfig(from: dictionary)
105-
} else {
87+
with: data, options: .fragmentsAllowed) as? [String: Any],
88+
let config = try? UserConfig(from: dictionary)
89+
else {
10690
Log.debug("Skipping cached config: no config found")
91+
return nil
10792
}
10893

10994
return config
11095
}
11196

11297
func getOrCreateAnonUserId() -> String {
113-
if let anonId = getAnonUserId() {
114-
return anonId
98+
if let existingId = getAnonUserId() {
99+
return existingId
115100
}
116-
let newAnonId = UUID().uuidString
117-
setAnonUserId(anonUserId: newAnonId)
118-
return newAnonId
119-
}
120101

121-
private func setString(key: String, value: String) {
122-
defaults.set(value, forKey: key)
102+
let newId = UUID().uuidString
103+
setAnonUserId(anonUserId: newId)
104+
return newId
123105
}
124106

125-
private func getString(key: String) -> String? {
126-
return defaults.string(forKey: key)
107+
// MARK: - Private Helper Methods
108+
109+
private func currentTimeMs() -> Int {
110+
return Int(Date().timeIntervalSince1970 * 1000)
127111
}
128112

129-
private func setInt(key: String, value: Int) {
130-
defaults.set(value, forKey: key)
113+
private func getIntValue(forKey key: String) -> Int? {
114+
return defaults.object(forKey: key) != nil ? defaults.integer(forKey: key) : nil
131115
}
132116

133-
private func getInt(key: String) -> Int? {
134-
return defaults.integer(forKey: key)
117+
private func cleanupCacheEntry(key: String) {
118+
defaults.removeObject(forKey: key)
119+
defaults.removeObject(forKey: "\(key)\(CacheKeys.expiryDateSuffix)")
135120
}
136121

137-
private func remove(key: String) {
122+
private func cleanupLegacyCacheEntry(key: String) {
138123
defaults.removeObject(forKey: key)
124+
defaults.removeObject(forKey: "\(key)\(CacheKeys.userIdSuffix)")
125+
defaults.removeObject(forKey: "\(key)\(CacheKeys.legacyFetchDateSuffix)")
139126
}
140127

141128
private func getConfigKeyPrefix(user: DevCycleUser) -> String {
142-
return (user.isAnonymous ?? false)
129+
let baseKey =
130+
(user.isAnonymous ?? false)
143131
? CacheKeys.anonymousConfigKey : CacheKeys.identifiedConfigKey
132+
133+
if let userId = user.userId {
134+
return "\(baseKey)_\(userId)"
135+
}
136+
137+
return baseKey
138+
}
139+
140+
// MARK: - Legacy Cache Migration
141+
142+
func migrateLegacyCache() {
143+
// Migrate config cache
144+
migrateConfigIfNeeded(oldKey: CacheKeys.identifiedConfigKey, isIdentified: true)
145+
migrateConfigIfNeeded(oldKey: CacheKeys.anonymousConfigKey, isIdentified: false)
146+
147+
// Clean up legacy user cache
148+
cleanupLegacyUserCache()
149+
150+
// Clean up legacy config cache
151+
cleanupLegacyConfigCache()
152+
}
153+
154+
private func cleanupLegacyUserCache() {
155+
if defaults.object(forKey: CacheKeys.legacyUser) != nil {
156+
defaults.removeObject(forKey: CacheKeys.legacyUser)
157+
Log.debug("Cleaned up legacy user cache")
158+
}
159+
}
160+
161+
private func cleanupLegacyConfigCache() {
162+
if defaults.object(forKey: CacheKeys.legacyConfig) != nil {
163+
defaults.removeObject(forKey: CacheKeys.legacyConfig)
164+
Log.debug("Cleaned up legacy config cache")
165+
}
166+
}
167+
168+
private func migrateConfigIfNeeded(oldKey: String, isIdentified: Bool) {
169+
guard let oldConfigData = defaults.object(forKey: oldKey) as? Data,
170+
let oldUserId = defaults.string(forKey: "\(oldKey)\(CacheKeys.userIdSuffix)")
171+
else {
172+
return
173+
}
174+
175+
let newKey =
176+
isIdentified
177+
? "\(CacheKeys.identifiedConfigKey)_\(oldUserId)"
178+
: "\(CacheKeys.anonymousConfigKey)_\(oldUserId)"
179+
180+
// If new cache already exists, just cleanup legacy cache
181+
if defaults.object(forKey: newKey) != nil {
182+
Log.debug("New cache key \(newKey) already exists, cleaning up legacy cache \(oldKey)")
183+
cleanupLegacyCacheEntry(key: oldKey)
184+
return
185+
}
186+
187+
// Migrate data to new format
188+
defaults.set(oldConfigData, forKey: newKey)
189+
190+
let expiryDate = currentTimeMs() + configCacheTTL
191+
defaults.set(expiryDate, forKey: "\(newKey)\(CacheKeys.expiryDateSuffix)")
192+
193+
// Cleanup old cache
194+
cleanupLegacyCacheEntry(key: oldKey)
195+
196+
Log.debug("Migrated config + user cache from \(oldKey) to \(newKey)")
144197
}
145198
}

0 commit comments

Comments
 (0)