Skip to content

Commit

Permalink
feat: Honor polling interval between restarts
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 18, 2024
1 parent 099ffe0 commit afb32ad
Show file tree
Hide file tree
Showing 13 changed files with 90 additions and 27 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 = 1
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,7 @@ protocol FeatureFlagCaching {
// sourcery: defaultMockValue = KeyedValueCachingMock()
var keyedValueCache: KeyedValueCaching { get }

func getCachedData(cacheKey: String) -> (items: StoredItems?, etag: String?)
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 +42,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
16 changes: 14 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,15 @@ class FlagSynchronizer: LDFlagSynchronizing, EventHandler {
return
}

if let lastTime = self.lastCachedRequestedTime {
let fireAt = lastTime.addingTimeInterval(pollingInterval)
flagRequestTimer = LDTimer(withTimeInterval: pollingInterval, fireQueue: syncQueue, fireAt: fireAt, execute: processTimer)
syncQueue.async { [self] in reportSyncComplete(.upToDate) }
} else {
flagRequestTimer = LDTimer(withTimeInterval: pollingInterval, fireQueue: syncQueue, execute: processTimer)
makeFlagRequest(isOnline: true)
}
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 +193,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
6 changes: 3 additions & 3 deletions LaunchDarkly/LaunchDarklyTests/LDClientSpec.swift
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ final class LDClientSpec: QuickSpec {
let mobileKey = self.serviceFactoryMock.makeFeatureFlagCacheReceivedParameters!.mobileKey
let mockCache = FeatureFlagCachingMock()
mockCache.getCachedDataCallback = {
mockCache.getCachedDataReturnValue = (items: StoredItems(items: self.cachedFlags[mobileKey]?[mockCache.getCachedDataReceivedCacheKey!] ?? [:]), etag: nil)
mockCache.getCachedDataReturnValue = (items: StoredItems(items: self.cachedFlags[mobileKey]?[mockCache.getCachedDataReceivedCacheKey!] ?? [:]), etag: nil, lastUpdated: nil)
}
self.serviceFactoryMock.makeFeatureFlagCacheReturnValue = mockCache
}
Expand Down Expand Up @@ -503,7 +503,7 @@ final class LDClientSpec: QuickSpec {
expect(testContext.serviceFactoryMock.makeEventReporterReceivedService?.context) == testContext.context
}
it("uncaches the new contexts flags") {
expect(testContext.featureFlagCachingMock.getCachedDataCallCount) == 1
expect(testContext.featureFlagCachingMock.getCachedDataCallCount) == 2
expect(testContext.featureFlagCachingMock.getCachedDataReceivedCacheKey) == testContext.context.contextHash()
}
it("records an identify event") {
Expand Down Expand Up @@ -542,7 +542,7 @@ final class LDClientSpec: QuickSpec {
expect(testContext.serviceFactoryMock.makeEventReporterReceivedService?.context) == testContext.context
}
it("uncaches the new contexts flags") {
expect(testContext.featureFlagCachingMock.getCachedDataCallCount) == 1
expect(testContext.featureFlagCachingMock.getCachedDataCallCount) == 2
expect(testContext.featureFlagCachingMock.getCachedDataReceivedCacheKey) == testContext.context.contextHash()
}
it("records an identify event") {
Expand Down
Loading

0 comments on commit afb32ad

Please sign in to comment.