diff --git a/LaunchDarkly.xcodeproj/project.pbxproj b/LaunchDarkly.xcodeproj/project.pbxproj index 58ffd6b1..e8a816d8 100644 --- a/LaunchDarkly.xcodeproj/project.pbxproj +++ b/LaunchDarkly.xcodeproj/project.pbxproj @@ -284,10 +284,11 @@ A3BA7D022BD192240000DB28 /* LDClientHookSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3BA7D012BD192240000DB28 /* LDClientHookSpec.swift */; }; A3BA7D042BD2BD620000DB28 /* TestContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3BA7D032BD2BD620000DB28 /* TestContext.swift */; }; A3C6F7622B7FA803005B3B61 /* SheddingQueueSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3C6F7612B7FA803005B3B61 /* SheddingQueueSpec.swift */; }; - A3C6F7642B84EF0C005B3B61 /* IdentifyResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3C6F7632B84EF0C005B3B61 /* IdentifyResult.swift */; }; - A3C6F7652B84EF0C005B3B61 /* IdentifyResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3C6F7632B84EF0C005B3B61 /* IdentifyResult.swift */; }; - A3C6F7662B84EF0C005B3B61 /* IdentifyResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3C6F7632B84EF0C005B3B61 /* IdentifyResult.swift */; }; - A3C6F7672B84EF0C005B3B61 /* IdentifyResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3C6F7632B84EF0C005B3B61 /* IdentifyResult.swift */; }; + A3C6F7642B84EF0C005B3B61 /* IdentifyTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3C6F7632B84EF0C005B3B61 /* IdentifyTypes.swift */; }; + A3C6F7652B84EF0C005B3B61 /* IdentifyTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3C6F7632B84EF0C005B3B61 /* IdentifyTypes.swift */; }; + A3C6F7662B84EF0C005B3B61 /* IdentifyTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3C6F7632B84EF0C005B3B61 /* IdentifyTypes.swift */; }; + A3C6F7672B84EF0C005B3B61 /* IdentifyTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3C6F7632B84EF0C005B3B61 /* IdentifyTypes.swift */; }; + A3F4A4812CC2F640006EF480 /* CwlPreconditionTesting in Frameworks */ = {isa = PBXBuildFile; productRef = A3F4A4802CC2F640006EF480 /* CwlPreconditionTesting */; }; A3FFE1132B7D4BA2009EF93F /* LDValueDecoderSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3FFE1122B7D4BA2009EF93F /* LDValueDecoderSpec.swift */; }; B40B419C249ADA6B00CD0726 /* DiagnosticCacheSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = B40B419B249ADA6B00CD0726 /* DiagnosticCacheSpec.swift */; }; B4265EB124E7390C001CFD2C /* TestUtil.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4265EB024E7390C001CFD2C /* TestUtil.swift */; }; @@ -511,7 +512,7 @@ A3BA7D012BD192240000DB28 /* LDClientHookSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LDClientHookSpec.swift; sourceTree = ""; }; A3BA7D032BD2BD620000DB28 /* TestContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestContext.swift; sourceTree = ""; }; A3C6F7612B7FA803005B3B61 /* SheddingQueueSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SheddingQueueSpec.swift; sourceTree = ""; }; - A3C6F7632B84EF0C005B3B61 /* IdentifyResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentifyResult.swift; sourceTree = ""; }; + A3C6F7632B84EF0C005B3B61 /* IdentifyTypes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentifyTypes.swift; sourceTree = ""; }; A3FFE1122B7D4BA2009EF93F /* LDValueDecoderSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LDValueDecoderSpec.swift; sourceTree = ""; }; B40B419B249ADA6B00CD0726 /* DiagnosticCacheSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiagnosticCacheSpec.swift; sourceTree = ""; }; B4265EB024E7390C001CFD2C /* TestUtil.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestUtil.swift; sourceTree = ""; }; @@ -726,7 +727,7 @@ 8354EFDE1F26380700C05156 /* Event.swift */, 83EBCB9D20D9A0A1003A7142 /* FeatureFlag */, 8354EFDD1F26380700C05156 /* LDConfig.swift */, - A3C6F7632B84EF0C005B3B61 /* IdentifyResult.swift */, + A3C6F7632B84EF0C005B3B61 /* IdentifyTypes.swift */, ); path = Models; sourceTree = ""; @@ -1346,7 +1347,7 @@ B4C9D43B2489E20A004A9B03 /* DiagnosticReporter.swift in Sources */, C443A40523145FBF00145710 /* ConnectionInformation.swift in Sources */, B468E71324B3C3AC00E0C883 /* ObjcLDEvaluationDetail.swift in Sources */, - A3C6F7672B84EF0C005B3B61 /* IdentifyResult.swift in Sources */, + A3C6F7672B84EF0C005B3B61 /* IdentifyTypes.swift in Sources */, 8354AC732243166900CDE602 /* FeatureFlagCache.swift in Sources */, 8311885B2113AE1D00D77CB5 /* Throttler.swift in Sources */, 8311884E2113ADE500D77CB5 /* Event.swift in Sources */, @@ -1375,7 +1376,7 @@ B468E71224B3C3AC00E0C883 /* ObjcLDEvaluationDetail.swift in Sources */, A36EDFCF2853C50B00D91B05 /* ObjcLDContext.swift in Sources */, 831EF34320655E730001C643 /* LDCommon.swift in Sources */, - A3C6F7662B84EF0C005B3B61 /* IdentifyResult.swift in Sources */, + A3C6F7662B84EF0C005B3B61 /* IdentifyTypes.swift in Sources */, 831EF34420655E730001C643 /* LDConfig.swift in Sources */, A31088212837DC0400184942 /* LDContext.swift in Sources */, 831EF34520655E730001C643 /* LDClient.swift in Sources */, @@ -1491,7 +1492,7 @@ 83B6C4B61F4DE7630055351C /* LDCommon.swift in Sources */, B4C9D4382489E20A004A9B03 /* DiagnosticReporter.swift in Sources */, 8347BB0C21F147E100E56BCD /* LDTimer.swift in Sources */, - A3C6F7642B84EF0C005B3B61 /* IdentifyResult.swift in Sources */, + A3C6F7642B84EF0C005B3B61 /* IdentifyTypes.swift in Sources */, B468E71024B3C3AC00E0C883 /* ObjcLDEvaluationDetail.swift in Sources */, 8354AC702243166900CDE602 /* FeatureFlagCache.swift in Sources */, A36EDFC82853883400D91B05 /* ObjcLDReference.swift in Sources */, @@ -1617,7 +1618,7 @@ 83D9EC922062DEAB004D7FA6 /* Data.swift in Sources */, 8347BB0D21F147E100E56BCD /* LDTimer.swift in Sources */, 8354AC712243166900CDE602 /* FeatureFlagCache.swift in Sources */, - A3C6F7652B84EF0C005B3B61 /* IdentifyResult.swift in Sources */, + A3C6F7652B84EF0C005B3B61 /* IdentifyTypes.swift in Sources */, C443A40323145FB700145710 /* ConnectionInformation.swift in Sources */, B4C9D4392489E20A004A9B03 /* DiagnosticReporter.swift in Sources */, B468E71124B3C3AC00E0C883 /* ObjcLDEvaluationDetail.swift in Sources */, diff --git a/LaunchDarkly/LaunchDarkly/LDClient.swift b/LaunchDarkly/LaunchDarkly/LDClient.swift index 3a6c0eef..9d905db3 100644 --- a/LaunchDarkly/LaunchDarkly/LDClient.swift +++ b/LaunchDarkly/LaunchDarkly/LDClient.swift @@ -292,7 +292,7 @@ public class LDClient { */ @available(*, deprecated, message: "Use LDClient.identify(context: completion:) with non-optional completion parameter") public func identify(context: LDContext, completion: (() -> Void)? = nil) { - _identify(context: context, sheddable: false) { _ in + _identify(context: context, sheddable: false, useCache: .yes) { _ in if let completion = completion { completion() } @@ -315,19 +315,33 @@ public class LDClient { - parameter completion: Closure called when the embedded `setOnlineIdentify` call completes, subject to throttling delays. */ public func identify(context: LDContext, completion: @escaping (_ result: IdentifyResult) -> Void) { - _identify(context: context, sheddable: true, completion: completion) + _identify(context: context, sheddable: true, useCache: .yes, completion: completion) + } + + /** + Sets the LDContext into the LDClient inline with the behavior detailed on `LDClient.identify(context: completion:)`. Additionally, + this method allows specifying how the flag cache should be handled when transitioning between contexts through the `useCache` parameter. + + To learn more about these cache transitions, refer to the `IdentifyCacheUsage` documentation. + + - parameter context: The LDContext set with the desired context. + - parameter useCache: How to handle flag caches during identify transition. + - parameter completion: Closure called when the embedded `setOnlineIdentify` call completes, subject to throttling delays. + */ + public func identify(context: LDContext, useCache: IdentifyCacheUsage, completion: @escaping (_ result: IdentifyResult) -> Void) { + _identify(context: context, sheddable: true, useCache: useCache, completion: completion) } // Temporary helper method to allow code sharing between the sheddable and unsheddable identify methods. In the next major release, we will remove the deprecated identify method and inline // this implementation in the other one. - private func _identify(context: LDContext, sheddable: Bool, completion: @escaping (_ result: IdentifyResult) -> Void) { + private func _identify(context: LDContext, sheddable: Bool, useCache: IdentifyCacheUsage, completion: @escaping (_ result: IdentifyResult) -> Void) { let work: TaskHandler = { taskCompletion in let dispatch = DispatchGroup() LDClient.instancesQueue.sync(flags: .barrier) { LDClient.instances?.forEach { _, instance in dispatch.enter() - instance.internalIdentify(newContext: context, completion: dispatch.leave) + instance.internalIdentify(newContext: context, useCache: useCache, completion: dispatch.leave) } } @@ -354,6 +368,21 @@ public class LDClient { - parameter completion: Closure called when the embedded `setOnlineIdentify` call completes, subject to throttling delays. */ public func identify(context: LDContext, timeout: TimeInterval, completion: @escaping ((_ result: IdentifyResult) -> Void)) { + identify(context: context, timeout: timeout, useCache: .yes, completion: completion) + } + + /** + Sets the LDContext into the LDClient inline with the behavior detailed on `LDClient.identify(context: timeout: completion:)`. Additionally, + this method allows specifying how the flag cache should be handled when transitioning between contexts through the `useCache` parameter. + + To learn more about these cache transitions, refer to the `IdentifyCacheUsage` documentation. + + - parameter context: The LDContext set with the desired context. + - parameter timeout: The upper time limit before the `completion` callback will be invoked. + - parameter useCache: How to handle flag caches during identify transition. + - parameter completion: Closure called when the embedded `setOnlineIdentify` call completes, subject to throttling delays. + */ + public func identify(context: LDContext, timeout: TimeInterval, useCache: IdentifyCacheUsage, completion: @escaping ((_ result: IdentifyResult) -> Void)) { if timeout > LDClient.longTimeoutInterval { os_log("%s LDClient.identify was called with a timeout greater than %f seconds. We recommend a timeout of less than %f seconds.", log: config.logger, type: .info, self.typeName(and: #function), LDClient.longTimeoutInterval, LDClient.longTimeoutInterval) } @@ -367,7 +396,7 @@ public class LDClient { completion(.timeout) } - identify(context: context) { result in + identify(context: context, useCache: useCache) { result in guard !cancel else { return } cancel = true @@ -375,7 +404,7 @@ public class LDClient { } } - func internalIdentify(newContext: LDContext, completion: (() -> Void)? = nil) { + func internalIdentify(newContext: LDContext, useCache: IdentifyCacheUsage, completion: (() -> Void)? = nil) { var updatedContext = newContext if config.autoEnvAttributes { updatedContext = AutoEnvContextModifier(environmentReporter: environmentReporter, logger: config.logger).modifyContext(updatedContext) @@ -394,27 +423,31 @@ public class LDClient { self.internalSetOnline(false) let cachedData = self.flagCache.getCachedData(cacheKey: self.context.fullyQualifiedHashedKey(), contextHash: self.context.contextHash()) - let cachedContextFlags = cachedData.items ?? [:] - let oldItems = flagStore.storedItems.featureFlags - - // Here we prime the store with the last known values from the - // cache. - // - // Once the flag sync. process finishes, the new payload is - // compared to this, and if they are different, change listeners - // will be notified; otherwise, they aren't. - // - // This is problematic since the flag values really did change. So - // we should trigger the change listener when we set these cache - // values. - // - // However, if there are no cached values, we don't want to inform - // customers that we set their store to nothing. In that case, we - // will not trigger the change listener and instead relay on the - // payload comparsion to do that when the request has completed. - flagStore.replaceStore(newStoredItems: cachedContextFlags) - if !cachedContextFlags.featureFlags.isEmpty { - flagChangeNotifier.notifyObservers(oldFlags: oldItems, newFlags: flagStore.storedItems.featureFlags) + + if useCache != .no { + let oldItems = flagStore.storedItems + let fallback = useCache == .yes ? [:] : oldItems + let cachedContextFlags = cachedData.items ?? fallback + + // Here we prime the store with the last known values from the + // cache. + // + // Once the flag sync. process finishes, the new payload is + // compared to this, and if they are different, change listeners + // will be notified; otherwise, they aren't. + // + // This is problematic since the flag values really did change. So + // we should trigger the change listener when we set these cache + // values. + // + // However, if there are no cached values, we don't want to inform + // customers that we set their store to nothing. In that case, we + // will not trigger the change listener and instead relay on the + // payload comparsion to do that when the request has completed. + flagStore.replaceStore(newStoredItems: cachedContextFlags) + if !cachedContextFlags.featureFlags.isEmpty { + flagChangeNotifier.notifyObservers(oldFlags: oldItems.featureFlags, newFlags: flagStore.storedItems.featureFlags) + } } self.service.context = self.context diff --git a/LaunchDarkly/LaunchDarkly/Models/IdentifyResult.swift b/LaunchDarkly/LaunchDarkly/Models/IdentifyResult.swift deleted file mode 100644 index 12075456..00000000 --- a/LaunchDarkly/LaunchDarkly/Models/IdentifyResult.swift +++ /dev/null @@ -1,34 +0,0 @@ -import Foundation - -/** - Denotes the result of an identify request made through the `LDClient.identify(context: completion:)` method. - */ -public enum IdentifyResult { - /** - The identify request has completed successfully. - */ - case complete - /** - The identify request has received an unrecoverable failure. - */ - case error - /** - The identify request has been replaced with a subsequent request. Read `LDClient.identify(context: completion:)` for more details. - */ - case shed - /** - The identify request exceeded some time out parameter. Read `LDClient.identify(context: timeout: completion)` for more details. - */ - case timeout - - init(from: TaskResult) { - switch from { - case .complete: - self = .complete - case .error: - self = .error - case .shed: - self = .shed - } - } -} diff --git a/LaunchDarkly/LaunchDarkly/Models/IdentifyTypes.swift b/LaunchDarkly/LaunchDarkly/Models/IdentifyTypes.swift new file mode 100644 index 00000000..2ccdcf80 --- /dev/null +++ b/LaunchDarkly/LaunchDarkly/Models/IdentifyTypes.swift @@ -0,0 +1,61 @@ +import Foundation + +/** + Denotes the result of an identify request made through the `LDClient.identify(context: completion:)` method. + */ +public enum IdentifyResult { + /** + The identify request has completed successfully. + */ + case complete + /** + The identify request has received an unrecoverable failure. + */ + case error + /** + The identify request has been replaced with a subsequent request. Read `LDClient.identify(context: completion:)` for more details. + */ + case shed + /** + The identify request exceeded some time out parameter. Read `LDClient.identify(context: timeout: completion)` for more details. + */ + case timeout + + init(from: TaskResult) { + switch from { + case .complete: + self = .complete + case .error: + self = .error + case .shed: + self = .shed + } + } +} + +/** + When a new `LDContext` is being identified, the SDK has a few choices it can make on how to handle intermediate flag evaluations + until fresh values have been retrieved from the LaunchDarkly APIs. + */ +public enum IdentifyCacheUsage { + /** + `no` will not load any flag values from the cache. Instead it will maintain the current in memory state from the previously identified context. + + This method ensures the greatest continuity of experience until the identify network communication resolves. + */ + case no + + /** + `yes` will clear the in memory state of any previously known flag values. The SDK will attempt to load cached flag values for the newly identified + context. If no cache is found, the state remains empty until the network request resolves. + */ + case yes + + /** + `ifAvailable` will attempt to load cached flag values for the newly identified context. If cached values are found, the in memory state is fully + replaced with those values. + + If no cached values are found, the existing in memory state is retained until the network request resolves. + */ + case ifAvailable +} diff --git a/LaunchDarkly/LaunchDarklyTests/LDClientSpec.swift b/LaunchDarkly/LaunchDarklyTests/LDClientSpec.swift index 44a65d37..9268eab8 100644 --- a/LaunchDarkly/LaunchDarklyTests/LDClientSpec.swift +++ b/LaunchDarkly/LaunchDarklyTests/LDClientSpec.swift @@ -158,7 +158,7 @@ final class LDClientSpec: QuickSpec { withTimeout ? testContext.start(timeOut: 10.0) : testContext.start() testContext.context = LDContext.stub() - testContext.subject.internalIdentify(newContext: testContext.context) + testContext.subject.internalIdentify(newContext: testContext.context, useCache: .yes) } it("saves the config") { expect(testContext.subject.config) == testContext.config @@ -442,7 +442,7 @@ final class LDClientSpec: QuickSpec { testContext.featureFlagCachingMock.reset() let newContext = LDContext.stub() - testContext.subject.internalIdentify(newContext: newContext) + testContext.subject.internalIdentify(newContext: newContext, useCache: .yes) expect(testContext.subject.context) == newContext expect(testContext.subject.service.context) == newContext @@ -464,7 +464,7 @@ final class LDClientSpec: QuickSpec { testContext.featureFlagCachingMock.reset() let newContext = LDContext.stub() - testContext.subject.internalIdentify(newContext: newContext) + testContext.subject.internalIdentify(newContext: newContext, useCache: .yes) expect(testContext.subject.context) == newContext expect(testContext.subject.service.context) == newContext @@ -487,7 +487,7 @@ final class LDClientSpec: QuickSpec { testContext.start() testContext.featureFlagCachingMock.reset() - testContext.subject.internalIdentify(newContext: newContext) + testContext.subject.internalIdentify(newContext: newContext, useCache: .yes) expect(testContext.subject.context) == newContext expect(testContext.flagStoreMock.replaceStoreCallCount) == 1 @@ -501,7 +501,7 @@ final class LDClientSpec: QuickSpec { testContext.featureFlagCachingMock.reset() let newContext = LDContext.stub() - testContext.subject.internalIdentify(newContext: newContext) + testContext.subject.internalIdentify(newContext: newContext, useCache: .yes) expect(newContext.contextKeys().count) < testContext.subject.service.context.contextKeys().count @@ -516,10 +516,10 @@ final class LDClientSpec: QuickSpec { testContext.start() testContext.featureFlagCachingMock.reset() - testContext.subject.internalIdentify(newContext: testContext.context) - testContext.subject.internalIdentify(newContext: testContext.context) - testContext.subject.internalIdentify(newContext: testContext.context) - testContext.subject.internalIdentify(newContext: testContext.context) + testContext.subject.internalIdentify(newContext: testContext.context, useCache: .yes) + testContext.subject.internalIdentify(newContext: testContext.context, useCache: .yes) + testContext.subject.internalIdentify(newContext: testContext.context, useCache: .yes) + testContext.subject.internalIdentify(newContext: testContext.context, useCache: .yes) expect(testContext.flagStoreMock.replaceStoreCallCount) == 0 expect(testContext.makeFlagSynchronizerService?.context) == testContext.context @@ -529,6 +529,52 @@ final class LDClientSpec: QuickSpec { expect(testContext.subject.flagSynchronizer.isOnline) == true expect(testContext.eventReporterMock.recordReceivedEvent?.kind == .identify).to(beTrue()) } + + it("no cache requires no store interaction") { + let testContext = TestContext(startOnline: true) + testContext.start() + testContext.featureFlagCachingMock.reset() + + testContext.subject.internalIdentify(newContext: testContext.context, useCache: .no) + + expect(testContext.flagStoreMock.replaceStoreCallCount) == 0 + expect(testContext.makeFlagSynchronizerService?.context) == testContext.context + + expect(testContext.subject.isOnline) == true + expect(testContext.subject.eventReporter.isOnline) == true + expect(testContext.subject.flagSynchronizer.isOnline) == true + expect(testContext.eventReporterMock.recordReceivedEvent?.kind == .identify).to(beTrue()) + } + + it("ifAvailable requires no store information on cache miss") { + let testContext = TestContext(startOnline: true) + testContext.start() + testContext.featureFlagCachingMock.reset() + + testContext.subject.internalIdentify(newContext: testContext.context, useCache: .ifAvailable) + + expect(testContext.flagStoreMock.replaceStoreCallCount) == 0 + expect(testContext.makeFlagSynchronizerService?.context) == testContext.context + + expect(testContext.subject.isOnline) == true + expect(testContext.subject.eventReporter.isOnline) == true + expect(testContext.subject.flagSynchronizer.isOnline) == true + expect(testContext.eventReporterMock.recordReceivedEvent?.kind == .identify).to(beTrue()) + } + + it("ifAvailable updates store when cache is present") { + let stubFlags = FlagMaintainingMock.stubStoredItems() + let newContext = LDContext.stub() + let testContext = TestContext().withCached(contextKey: newContext.fullyQualifiedHashedKey(), flags: stubFlags.featureFlags) + testContext.start() + testContext.featureFlagCachingMock.reset() + + testContext.subject.internalIdentify(newContext: newContext, useCache: .ifAvailable) + + expect(testContext.subject.context) == newContext + expect(testContext.flagStoreMock.replaceStoreCallCount) == 1 + expect(testContext.flagStoreMock.replaceStoreReceivedNewFlags) == stubFlags + } } }