Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Honor polling interval between restarts #355

Merged
merged 3 commits into from
Mar 20, 2024
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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?)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing documentation. The other method also is missing documentation around lastUpdated.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These methods are internal and so won't be reflected in the public documentation. I have still added some additional documentation.


// 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)))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just to confirm here, the lastUpdate from the cache is millis, but the TimeInterval takes in seconds as a float?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes.

A TimeInterval is always in seconds.

You can see here that the timestamp in the cache is millis.

}

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) }
}
Copy link
Contributor

@tanderson-ld tanderson-ld Mar 19, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems like it should be possible to make these two code paths converge. If the fireAt is in the past, does the LDTimer fire immediately?

If so, seems like defaulting last cached requested time to epoch when it is missing should work.

Perhaps I'm missing why makeFlagRequest(...) is in one path but not the other and that reason I'm missing is why the paths can't converge?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It was affecting unit test execution order. I've updated the unit tests and combined these two code paths.

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
Loading