Skip to content

Commit

Permalink
feat: Honor polling interval between restarts (#355)
Browse files Browse the repository at this point in the history
If an application receives a flag update and then is closed, only to be
opened back up, we shouldn't need to make another polling request
immediately as the data is within the acceptable freshness threshold.

The same is true when the app is put in the background and brought
forward again.
  • Loading branch information
keelerm84 committed Mar 20, 2024
1 parent 099ffe0 commit bd58864
Show file tree
Hide file tree
Showing 13 changed files with 171 additions and 57 deletions.
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ jobs:
runs-on: ${{ matrix.os }}

strategy:
fail-fast: false
matrix:
include:
- xcode-version: 15.0.1
Expand Down
4 changes: 3 additions & 1 deletion ContractTests/Source/Controllers/SdkController.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import Vapor
import LaunchDarkly
@testable import LaunchDarkly

final class SdkController: RouteCollection {
private var clients: [Int: LDClient] = [:]
Expand Down Expand Up @@ -50,6 +50,8 @@ final class SdkController: RouteCollection {
// TODO(mmk) Need to hook up initialRetryDelayMs
} else if let polling = createInstance.configuration.polling {
config.streamingMode = .polling
config.ignorePollingMinimum = true
config.flagPollingInterval = 0.5
if let baseUri = polling.baseUri {
config.baseUrl = URL(string: baseUri)!
}
Expand Down
4 changes: 2 additions & 2 deletions LaunchDarkly/GeneratedCode/mocks.generated.swift
Original file line number Diff line number Diff line change
Expand Up @@ -247,8 +247,8 @@ final class FeatureFlagCachingMock: FeatureFlagCaching {
var getCachedDataCallCount = 0
var getCachedDataCallback: (() throws -> Void)?
var getCachedDataReceivedCacheKey: String?
var getCachedDataReturnValue: (items: StoredItems?, etag: String?)!
func getCachedData(cacheKey: String) -> (items: StoredItems?, etag: String?) {
var getCachedDataReturnValue: (items: StoredItems?, etag: String?, lastUpdated: Date?)!
func getCachedData(cacheKey: String) -> (items: StoredItems?, etag: String?, lastUpdated: Date?) {
getCachedDataCallCount += 1
getCachedDataReceivedCacheKey = cacheKey
try! getCachedDataCallback?()
Expand Down
23 changes: 22 additions & 1 deletion LaunchDarkly/LaunchDarkly/LDClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -202,13 +202,16 @@ public class LDClient {
return
}

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

let willSetSynchronizerOnline = isOnline && isInSupportedRunMode
flagSynchronizer.isOnline = false
let streamingModeVar = ConnectionInformation.effectiveStreamingMode(config: config, ldClient: self)
connectionInformation = ConnectionInformation.backgroundBehavior(connectionInformation: connectionInformation, streamingMode: streamingModeVar, goOnline: willSetSynchronizerOnline)
flagSynchronizer = serviceFactory.makeFlagSynchronizer(streamingMode: streamingModeVar,
pollingInterval: config.flagPollingInterval(runMode: runMode),
useReport: config.useReport,
lastUpdated: cachedData.lastUpdated,
service: service,
onSyncComplete: onFlagSyncComplete)
flagSynchronizer.isOnline = willSetSynchronizerOnline
Expand Down Expand Up @@ -404,6 +407,7 @@ public class LDClient {
flagSynchronizer = serviceFactory.makeFlagSynchronizer(streamingMode: ConnectionInformation.effectiveStreamingMode(config: config, ldClient: self),
pollingInterval: config.flagPollingInterval(runMode: runMode),
useReport: config.useReport,
lastUpdated: cachedData.lastUpdated,
service: self.service,
onSyncComplete: self.onFlagSyncComplete)

Expand Down Expand Up @@ -591,6 +595,10 @@ public class LDClient {
case .upToDate:
connectionInformation.lastKnownFlagValidity = Date()
flagChangeNotifier.notifyUnchanged()
// If a polling request receives a 304 not modified, we still need
// to update the "last updated" field of the cache so subsequent
// restarts will honor the appropriate polling delay.
self.updateCacheFreshness(context: self.context)
case .error(let synchronizingError):
process(synchronizingError, logPrefix: typeName(and: #function))
}
Expand All @@ -610,6 +618,17 @@ public class LDClient {
flagChangeNotifier.notifyObservers(oldFlags: oldStoredItems.featureFlags, newFlags: flagStore.storedItems.featureFlags)
}

/**
This method will update the lastUpdated timestamp on the cache with the current time.
When a polling request returns a 304 not modified, we need to update this value so subsequent restarts still honor the appropriate polling interval delay.
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)
}

// MARK: Events

/**
Expand Down Expand Up @@ -820,9 +839,11 @@ 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())
flagSynchronizer = self.serviceFactory.makeFlagSynchronizer(streamingMode: config.allowStreamingMode ? config.streamingMode : .polling,
pollingInterval: config.flagPollingInterval(runMode: runMode),
useReport: config.useReport,
lastUpdated: cachedData.lastUpdated,
service: service)

if let backgroundNotification = SystemCapabilities.backgroundNotification {
Expand All @@ -835,11 +856,11 @@ public class LDClient {
NotificationCenter.default.addObserver(self, selector: #selector(didCloseEventSource), name: Notification.Name(FlagSynchronizer.Constants.didCloseEventSourceName), object: nil)

eventReporter = self.serviceFactory.makeEventReporter(service: service, onSyncComplete: onEventSyncComplete)
let cachedData = flagCache.getCachedData(cacheKey: context.contextHash())
service.resetFlagResponseCache(etag: cachedData.etag)
flagSynchronizer = self.serviceFactory.makeFlagSynchronizer(streamingMode: config.allowStreamingMode ? config.streamingMode : .polling,
pollingInterval: config.flagPollingInterval(runMode: runMode),
useReport: config.useReport,
lastUpdated: cachedData.lastUpdated,
service: service,
onSyncComplete: onFlagSyncComplete)

Expand Down
8 changes: 8 additions & 0 deletions LaunchDarkly/LaunchDarkly/Models/LDConfig.swift
Original file line number Diff line number Diff line change
Expand Up @@ -371,6 +371,10 @@ public struct LDConfig {
/// Enables logging for debugging. (Default: false)
public var isDebugMode: Bool = Defaults.debugMode

/// Used by the contract tests to override the default 5 minute polling interval. This should never be used outside
/// of the contract tests.
internal var ignorePollingMinimum: Bool = false

/// Enables requesting evaluation reasons for all flags. (Default: false)
public var evaluationReasons: Bool = Defaults.evaluationReasons

Expand Down Expand Up @@ -492,6 +496,10 @@ public struct LDConfig {

// Determine the effective flag polling interval based on runMode, configured foreground & background polling interval, and minimum foreground & background polling interval.
func flagPollingInterval(runMode: LDClientRunMode) -> TimeInterval {
if ignorePollingMinimum {
return runMode == .foreground ? flagPollingInterval : backgroundFlagPollingInterval
}

return runMode == .foreground ? max(flagPollingInterval, minima.flagPollingInterval) : max(backgroundFlagPollingInterval, minima.backgroundFlagPollingInterval)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,18 @@ protocol FeatureFlagCaching {
// sourcery: defaultMockValue = KeyedValueCachingMock()
var keyedValueCache: KeyedValueCaching { get }

func getCachedData(cacheKey: String) -> (items: StoredItems?, etag: String?)
/// Retrieve all cached data for the given cache key.
///
/// - parameter cacheKey: The unique key into the local cache store.
/// - 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
/// comments) for more informmation.
/// lastUpdated: The date the cache was last considered up-to-date. If there are no cached
/// values, this should return nil.
///
///
func getCachedData(cacheKey: 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 Down Expand Up @@ -42,16 +53,24 @@ final class FeatureFlagCache: FeatureFlagCaching {
self.maxCachedContexts = maxCachedContexts
}

func getCachedData(cacheKey: String) -> (items: StoredItems?, etag: String?) {
func getCachedData(cacheKey: 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) }
else { return (items: nil, 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) }
else { return (items: cachedFlags.flags, etag: nil, lastUpdated: nil) }

return (items: cachedFlags.flags, etag: etag)
var cachedContexts: [String: Int64] = [:]
if let cacheMetadata = keyedValueCache.data(forKey: "cached-contexts") {
cachedContexts = (try? JSONDecoder().decode([String: Int64].self, from: cacheMetadata)) ?? [:]
}

guard let lastUpdated = cachedContexts[cacheKey]
else { return (items: cachedFlags.flags, etag: etag, lastUpdated: nil) }

return (items: cachedFlags.flags, etag: etag, lastUpdated: Date(timeIntervalSince1970: TimeInterval(lastUpdated / 1_000)))
}

func saveCachedData(_ storedItems: StoredItems, cacheKey: String, lastUpdated: Date, etag: String?) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,11 @@ protocol ClientServiceCreating {
func makeFeatureFlagCache(mobileKey: String, maxCachedContexts: Int) -> FeatureFlagCaching
func makeCacheConverter() -> CacheConverting
func makeDarklyServiceProvider(config: LDConfig, context: LDContext, envReporter: EnvironmentReporting) -> DarklyServiceProvider
func makeFlagSynchronizer(streamingMode: LDStreamingMode, pollingInterval: TimeInterval, useReport: Bool, service: DarklyServiceProvider) -> LDFlagSynchronizing
func makeFlagSynchronizer(streamingMode: LDStreamingMode, pollingInterval: TimeInterval, useReport: Bool, lastUpdated: Date?, service: DarklyServiceProvider) -> LDFlagSynchronizing
func makeFlagSynchronizer(streamingMode: LDStreamingMode,
pollingInterval: TimeInterval,
useReport: Bool,
lastUpdated: Date?,
service: DarklyServiceProvider,
onSyncComplete: FlagSyncCompleteClosure?) -> LDFlagSynchronizing
func makeFlagChangeNotifier() -> FlagChangeNotifying
Expand Down Expand Up @@ -48,16 +49,17 @@ final class ClientServiceFactory: ClientServiceCreating {
DarklyService(config: config, context: context, envReporter: envReporter, serviceFactory: self)
}

func makeFlagSynchronizer(streamingMode: LDStreamingMode, pollingInterval: TimeInterval, useReport: Bool, service: DarklyServiceProvider) -> LDFlagSynchronizing {
makeFlagSynchronizer(streamingMode: streamingMode, pollingInterval: pollingInterval, useReport: useReport, service: service, onSyncComplete: nil)
func makeFlagSynchronizer(streamingMode: LDStreamingMode, pollingInterval: TimeInterval, useReport: Bool, lastUpdated: Date?, service: DarklyServiceProvider) -> LDFlagSynchronizing {
makeFlagSynchronizer(streamingMode: streamingMode, pollingInterval: pollingInterval, useReport: useReport, lastUpdated: lastUpdated, service: service, onSyncComplete: nil)
}

func makeFlagSynchronizer(streamingMode: LDStreamingMode,
pollingInterval: TimeInterval,
useReport: Bool,
lastUpdated: Date?,
service: DarklyServiceProvider,
onSyncComplete: FlagSyncCompleteClosure?) -> LDFlagSynchronizing {
FlagSynchronizer(streamingMode: streamingMode, pollingInterval: pollingInterval, useReport: useReport, service: service, onSyncComplete: onSyncComplete)
FlagSynchronizer(streamingMode: streamingMode, pollingInterval: pollingInterval, useReport: useReport, lastUpdated: lastUpdated, service: service, onSyncComplete: onSyncComplete)
}

func makeFlagChangeNotifier() -> FlagChangeNotifying {
Expand Down
18 changes: 16 additions & 2 deletions LaunchDarkly/LaunchDarkly/ServiceObjects/FlagSynchronizer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -79,18 +79,21 @@ class FlagSynchronizer: LDFlagSynchronizing, EventHandler {
private var isOnlineQueue = DispatchQueue(label: "com.launchdarkly.FlagSynchronizer.isOnlineQueue")
let pollingInterval: TimeInterval
let useReport: Bool
private var lastCachedRequestedTime: Date?

private var syncQueue = DispatchQueue(label: Constants.queueName, qos: .utility)
private var eventSourceStarted: Date?

init(streamingMode: LDStreamingMode,
pollingInterval: TimeInterval,
useReport: Bool,
lastUpdated: Date?,
service: DarklyServiceProvider,
onSyncComplete: FlagSyncCompleteClosure?) {
self.streamingMode = streamingMode
self.pollingInterval = pollingInterval
self.useReport = useReport
self.lastCachedRequestedTime = lastUpdated
self.service = service
self.onSyncComplete = onSyncComplete
os_log("%s streamingMode: %s pollingInterval: %s useReport: %s", log: service.config.logger, type: .debug, typeName(and: #function), String(describing: streamingMode), String(describing: pollingInterval), useReport.description)
Expand Down Expand Up @@ -151,9 +154,17 @@ class FlagSynchronizer: LDFlagSynchronizing, EventHandler {
return
}

// We should fire right away, unless we know how fresh the cache is and can
// adjust accordingly.
var fireAt = Date.distantPast
if let lastTime = self.lastCachedRequestedTime {
fireAt = lastTime.addingTimeInterval(pollingInterval)
// If we do consider the cached values already fresh enough, we should
// signal completion immediately
syncQueue.async { [self] in reportSyncComplete(.upToDate) }
}
flagRequestTimer = LDTimer(withTimeInterval: pollingInterval, fireQueue: syncQueue, fireAt: fireAt, execute: processTimer)
os_log("%s", log: service.config.logger, type: .debug, typeName(and: #function))
flagRequestTimer = LDTimer(withTimeInterval: pollingInterval, fireQueue: syncQueue, execute: processTimer)
makeFlagRequest(isOnline: true)
}

private func stopPolling() {
Expand Down Expand Up @@ -184,6 +195,9 @@ class FlagSynchronizer: LDFlagSynchronizing, EventHandler {
os_log("%s starting", log: service.config.logger, type: .debug, typeName(and: #function))
let context = (useReport: useReport,
logPrefix: typeName(and: #function))
// We blank this value here so that future `startPolling` requests do
// not prematurely trigger the sync completion.
self.lastCachedRequestedTime = nil
service.getFeatureFlags(useReport: useReport) { [weak self] serviceResponse in
if FlagSynchronizer.shouldRetryFlagRequest(useReport: context.useReport, statusCode: (serviceResponse.urlResponse as? HTTPURLResponse)?.statusCode) {
if let myself = self {
Expand Down
11 changes: 8 additions & 3 deletions LaunchDarkly/LaunchDarkly/ServiceObjects/LDTimer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import Foundation
protocol TimeResponding {
var fireDate: Date? { get }

init(withTimeInterval: TimeInterval, fireQueue: DispatchQueue, execute: @escaping () -> Void)
init(withTimeInterval: TimeInterval, fireQueue: DispatchQueue, fireAt: Date?, execute: @escaping () -> Void)
func cancel()
}

Expand All @@ -15,12 +15,17 @@ final class LDTimer: TimeResponding {
private (set) var isCancelled: Bool = false
var fireDate: Date? { timer?.fireDate }

init(withTimeInterval timeInterval: TimeInterval, fireQueue: DispatchQueue = DispatchQueue.main, execute: @escaping () -> Void) {
init(withTimeInterval timeInterval: TimeInterval, fireQueue: DispatchQueue = DispatchQueue.main, fireAt: Date? = nil, execute: @escaping () -> Void) {
self.fireQueue = fireQueue
self.execute = execute

// the run loop retains the timer, so the property is weak to avoid a retain cycle. Setting the timer to a strong reference is important so that the timer doesn't get nil'd before it's added to the run loop.
let timer = Timer(timeInterval: timeInterval, target: self, selector: #selector(timerFired), userInfo: nil, repeats: true)
let timer: Timer
if let at = fireAt {
timer = Timer(fireAt: at, interval: timeInterval, target: self, selector: #selector(timerFired), userInfo: nil, repeats: true)
} else {
timer = Timer(timeInterval: timeInterval, target: self, selector: #selector(timerFired), userInfo: nil, repeats: true)
}
self.timer = timer
RunLoop.main.add(timer, forMode: RunLoop.Mode.default)
}
Expand Down
Loading

0 comments on commit bd58864

Please sign in to comment.