Skip to content

Commit

Permalink
feat: Update caching strategy to allow for greater cache use (#404)
Browse files Browse the repository at this point in the history
Instead of indexing by the full context's hash, we are going to revert
to indexing by canonical key. The cache will store the hash alongside
the flag values.

This stored hash will be compared with the active context hash when the
cache is read. If the hashes are different, the SDK will fetch updated
values. If they haven't changed, then the SDK is free to wait until the
cache freshness has exceeded the configured polling interval.

As a result, the SDK should have a smoother transition from

    default -> last known values -> fresh values

as the context changes while also minimizing unnecessary API requests.
  • Loading branch information
keelerm84 authored Aug 7, 2024
1 parent 90bf896 commit 62587ad
Show file tree
Hide file tree
Showing 7 changed files with 126 additions and 56 deletions.
12 changes: 6 additions & 6 deletions LaunchDarkly/GeneratedCode/mocks.generated.swift
Original file line number Diff line number Diff line change
Expand Up @@ -246,21 +246,21 @@ final class FeatureFlagCachingMock: FeatureFlagCaching {

var getCachedDataCallCount = 0
var getCachedDataCallback: (() throws -> Void)?
var getCachedDataReceivedCacheKey: String?
var getCachedDataReceivedArguments: (cacheKey: String, contextHash: String)?
var getCachedDataReturnValue: (items: StoredItems?, etag: String?, lastUpdated: Date?)!
func getCachedData(cacheKey: String) -> (items: StoredItems?, etag: String?, lastUpdated: Date?) {
func getCachedData(cacheKey: String, contextHash: String) -> (items: StoredItems?, etag: String?, lastUpdated: Date?) {
getCachedDataCallCount += 1
getCachedDataReceivedCacheKey = cacheKey
getCachedDataReceivedArguments = (cacheKey: cacheKey, contextHash: contextHash)
try! getCachedDataCallback?()
return getCachedDataReturnValue
}

var saveCachedDataCallCount = 0
var saveCachedDataCallback: (() throws -> Void)?
var saveCachedDataReceivedArguments: (storedItems: StoredItems, cacheKey: String, lastUpdated: Date, etag: String?)?
func saveCachedData(_ storedItems: StoredItems, cacheKey: String, lastUpdated: Date, etag: String?) {
var saveCachedDataReceivedArguments: (storedItems: StoredItems, cacheKey: String, contextHash: String, lastUpdated: Date, etag: String?)?
func saveCachedData(_ storedItems: StoredItems, cacheKey: String, contextHash: String, lastUpdated: Date, etag: String?) {
saveCachedDataCallCount += 1
saveCachedDataReceivedArguments = (storedItems: storedItems, cacheKey: cacheKey, lastUpdated: lastUpdated, etag: etag)
saveCachedDataReceivedArguments = (storedItems: storedItems, cacheKey: cacheKey, contextHash: contextHash, lastUpdated: lastUpdated, etag: etag)
try! saveCachedDataCallback?()
}
}
Expand Down
10 changes: 5 additions & 5 deletions LaunchDarkly/LaunchDarkly/LDClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -208,7 +208,7 @@ public class LDClient {
return
}

let cachedData = self.flagCache.getCachedData(cacheKey: self.context.contextHash())
let cachedData = self.flagCache.getCachedData(cacheKey: self.context.fullyQualifiedHashedKey(), contextHash: self.context.contextHash())

let willSetSynchronizerOnline = isOnline && isInSupportedRunMode
flagSynchronizer.isOnline = false
Expand Down Expand Up @@ -393,7 +393,7 @@ public class LDClient {
let wasOnline = self.isOnline
self.internalSetOnline(false)

let cachedData = self.flagCache.getCachedData(cacheKey: self.context.contextHash())
let cachedData = self.flagCache.getCachedData(cacheKey: self.context.fullyQualifiedHashedKey(), contextHash: self.context.contextHash())
let cachedContextFlags = cachedData.items ?? [:]
let oldItems = flagStore.storedItems.featureFlags

Expand Down Expand Up @@ -629,7 +629,7 @@ public class LDClient {

private func updateCacheAndReportChanges(context: LDContext,
oldStoredItems: StoredItems, etag: String?) {
flagCache.saveCachedData(flagStore.storedItems, cacheKey: context.contextHash(), lastUpdated: Date(), etag: etag)
flagCache.saveCachedData(flagStore.storedItems, cacheKey: context.fullyQualifiedHashedKey(), contextHash: context.contextHash(), lastUpdated: Date(), etag: etag)
flagChangeNotifier.notifyObservers(oldFlags: oldStoredItems.featureFlags, newFlags: flagStore.storedItems.featureFlags)
}

Expand All @@ -641,7 +641,7 @@ public class LDClient {
In other words, if we get confirmation our cache is still fresh, then we shouldn't poll again for another <pollingInterval> seconds. If we didn't update this, we would poll immediately on restart.
*/
private func updateCacheFreshness(context: LDContext) {
flagCache.saveCachedData(flagStore.storedItems, cacheKey: context.contextHash(), lastUpdated: Date(), etag: nil)
flagCache.saveCachedData(flagStore.storedItems, cacheKey: context.fullyQualifiedHashedKey(), contextHash: context.contextHash(), lastUpdated: Date(), etag: nil)
}

// MARK: Events
Expand Down Expand Up @@ -881,7 +881,7 @@ public class LDClient {
diagnosticReporter = self.serviceFactory.makeDiagnosticReporter(service: service, environmentReporter: environmentReporter)
eventReporter = self.serviceFactory.makeEventReporter(service: service)
connectionInformation = self.serviceFactory.makeConnectionInformation()
let cachedData = flagCache.getCachedData(cacheKey: context.contextHash())
let cachedData = flagCache.getCachedData(cacheKey: context.fullyQualifiedHashedKey(), contextHash: context.contextHash())
flagSynchronizer = self.serviceFactory.makeFlagSynchronizer(streamingMode: config.allowStreamingMode ? config.streamingMode : .polling,
pollingInterval: config.flagPollingInterval(runMode: runMode),
useReport: config.useReport,
Expand Down
4 changes: 2 additions & 2 deletions LaunchDarkly/LaunchDarkly/Models/Context/LDContext.swift
Original file line number Diff line number Diff line change
Expand Up @@ -364,12 +364,12 @@ public struct LDContext: Encodable, Equatable {
encoder.userInfo[UserInfoKeys.redactAttributes] = false

guard let json = try? encoder.encode(self)
else { return fullyQualifiedKey() }
else { return fullyQualifiedHashedKey() }

if let jsonStr = String(data: json, encoding: .utf8) {
return Util.sha256base64(jsonStr)
}
return fullyQualifiedKey()
return fullyQualifiedHashedKey()
}

/// - Returns: true if the `LDContext` is a multi-context; false otherwise.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,22 @@ protocol FeatureFlagCaching {

/// Retrieve all cached data for the given cache key.
///
/// - parameter cacheKey: The unique key into the local cache store.
/// The cache key is used as the index into the cache. Values retrieved
/// using this cache key should be loaded into the store and favored over
/// the default values.
///
/// The context hash value is used to determine if the cache is considered
/// out of date. If the hash saved alongside the cached value does not
/// match, then the cache's etag and lastUpdated responses should be nil as
/// they are invalid.
///
/// If the hash hasn't changed, then the cache is still considered accurate
/// and the associated etag and last updated values are meaningful and can
/// be returned.
///
/// - parameter cacheKey: The index key into the local cache store.
/// - parameter contextHash: A hash value representing a fully unique context.
///
/// - returns: Returns a tuple of cached value information.
/// items: This is the associated flag evaluation results associated with this context.
/// etag: The last known e-tag value from a polling request (see saveCachedData
Expand All @@ -16,7 +31,7 @@ protocol FeatureFlagCaching {
/// values, this should return nil.
///
///
func getCachedData(cacheKey: String) -> (items: StoredItems?, etag: String?, lastUpdated: Date?)
func getCachedData(cacheKey: String, contextHash: String) -> (items: StoredItems?, etag: String?, lastUpdated: Date?)

// When we update the cache, we save the flag data and if we have it, an
// etag. For polling, we should always have the flag data and an etag
Expand All @@ -35,7 +50,11 @@ protocol FeatureFlagCaching {
//
// 2. Updates have been made at which point the e-tag will be ignored
// upstream and we will still receive updated information as expected.
func saveCachedData(_ storedItems: StoredItems, cacheKey: String, lastUpdated: Date, etag: String?)
//
// The context hash is stored alongside the stored items. This is used as a
// marker to determine when the values are useful but not potentially
// accurate.
func saveCachedData(_ storedItems: StoredItems, cacheKey: String, contextHash: String, lastUpdated: Date, etag: String?)
}

final class FeatureFlagCache: FeatureFlagCaching {
Expand All @@ -53,11 +72,19 @@ final class FeatureFlagCache: FeatureFlagCaching {
self.maxCachedContexts = maxCachedContexts
}

func getCachedData(cacheKey: String) -> (items: StoredItems?, etag: String?, lastUpdated: Date?) {
func getCachedData(cacheKey: String, contextHash: String) -> (items: StoredItems?, etag: String?, lastUpdated: Date?) {

guard let cachedFlagsData = keyedValueCache.data(forKey: "flags-\(cacheKey)"),
let cachedFlags = try? JSONDecoder().decode(StoredItemCollection.self, from: cachedFlagsData)
else { return (items: nil, etag: nil, lastUpdated: nil) }

guard let cachedContextHashData = keyedValueCache.data(forKey: "fingerprint-\(cacheKey)"),
let cachedContextHash = try? JSONDecoder().decode(String.self, from: cachedContextHashData)
else { return (items: cachedFlags.flags, etag: nil, lastUpdated: nil) }

guard cachedContextHash == contextHash
else { return (items: cachedFlags.flags, etag: nil, lastUpdated: nil) }

guard let cachedETagData = keyedValueCache.data(forKey: "etag-\(cacheKey)"),
let etag = try? JSONDecoder().decode(String.self, from: cachedETagData)
else { return (items: cachedFlags.flags, etag: nil, lastUpdated: nil) }
Expand All @@ -73,14 +100,19 @@ final class FeatureFlagCache: FeatureFlagCaching {
return (items: cachedFlags.flags, etag: etag, lastUpdated: Date(timeIntervalSince1970: TimeInterval(lastUpdated / 1_000)))
}

func saveCachedData(_ storedItems: StoredItems, cacheKey: String, lastUpdated: Date, etag: String?) {
func saveCachedData(_ storedItems: StoredItems, cacheKey: String, contextHash: String, lastUpdated: Date, etag: String?) {

guard self.maxCachedContexts != 0, let encoded = try? JSONEncoder().encode(StoredItemCollection(storedItems))
else { return }

self.keyedValueCache.set(encoded, forKey: "flags-\(cacheKey)")

if let tag = etag, let encodedCachedData = try? JSONEncoder().encode(tag) {
self.keyedValueCache.set(encodedCachedData, forKey: "etag-\(cacheKey)")
if let encodedContextHashData = try? JSONEncoder().encode(contextHash) {
self.keyedValueCache.set(encodedContextHashData, forKey: "fingerprint-\(cacheKey)")

if let tag = etag, let encodedCachedData = try? JSONEncoder().encode(tag) {
self.keyedValueCache.set(encodedCachedData, forKey: "etag-\(cacheKey)")
}
}

var cachedContexts: [String: Int64] = [:]
Expand All @@ -94,6 +126,7 @@ final class FeatureFlagCache: FeatureFlagCaching {
cachedContexts.removeValue(forKey: sha)
self.keyedValueCache.removeObject(forKey: "flags-\(sha)")
self.keyedValueCache.removeObject(forKey: "etag-\(sha)")
self.keyedValueCache.removeObject(forKey: "fingerprint-\(sha)")
}
}
if let encoded = try? JSONEncoder().encode(cachedContexts) {
Expand Down
Loading

0 comments on commit 62587ad

Please sign in to comment.