Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
253 changes: 200 additions & 53 deletions DevCycle/DevCycleClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ public typealias ClientInitializedHandler = (Error?) -> Void
public typealias IdentifyCompletedHandler = (Error?, [String: Variable]?) -> Void
public typealias FlushCompletedHandler = (Error?) -> Void
public typealias CloseCompletedHandler = () -> Void
public typealias ConfigUpdatedHandler = (Error?) -> Void

public class DevCycleClient {
var sdkKey: String?
Expand All @@ -37,7 +38,12 @@ public class DevCycleClient {
var options: DevCycleOptions?
var configCompletionHandlers: [ClientInitializedHandler] = []
var initialized: Bool = false
private var isConfigCached: Bool = false
var eventQueue: EventQueue = EventQueue()
private let configUpdateQueue = DispatchQueue(label: "com.devcycle.ConfigUpdateQueue")
private var configUpdatedCallbacks: [ConfigUpdatedHandler] = []
private var hasPendingConfigUpdate: Bool = false
private var pendingConfigUpdateError: Error?
private let defaultFlushInterval: Int = 10000
private var flushEventsInterval: Double = 10.0
private var enableEdgeDB: Bool = false
Expand All @@ -64,9 +70,8 @@ public class DevCycleClient {
return
}

// Only create new cache service if configCacheTTL is specified
if let configCacheTTL = self.options?.configCacheTTL {
self.cacheService = CacheService(configCacheTTL: configCacheTTL)
if let options = self.options {
self.cacheService = CacheService(configCacheTTL: options.configCacheTTL)
}
Comment thread
luxscious marked this conversation as resolved.

self.config = DVCConfig(sdkKey: sdkKey, user: user)
Expand Down Expand Up @@ -135,66 +140,51 @@ public class DevCycleClient {
#endif
}

/**
Setup client with the DevCycleService and the callback
*/
/// On a cache hit, returns synchronously from the persisted config and refreshes
/// in the background (observe via `onConfigUpdated(_:)`). On a cache miss, falls
/// back to the network-first path.
func setup(service: DevCycleServiceProtocol, callback: ClientInitializedHandler? = nil) {
guard let user = self.user else {
callback?(ClientError.MissingSDKKeyOrUser)
return
}
self.service = service

let _ = self.useCachedConfigForUser(user: user)
let cacheHit = self.useCachedConfigForUser(user: user)

self.service?.getConfig(
user: user, enableEdgeDB: self.enableEdgeDB, extraParams: nil,
completion: { [weak self] config, error in
guard let self = self else { return }
if cacheHit {
self.deliverInitializationComplete(error: nil, callback: callback)
self.performBackgroundRefresh()
} else {
self.service?.getConfig(
user: user, enableEdgeDB: self.enableEdgeDB, extraParams: nil,
completion: { [weak self] config, error in
guard let self = self else { return }

var finalError: Error? = error
var finalError: Error? = error

if let error = error {
Log.error("Error getting config: \(error)", tags: ["setup"])
if let error = error {
Log.error("Error getting config: \(error)", tags: ["setup"])

// If network failed but we have a cached config, don't return error
if self.config?.getUserConfig() != nil {
Log.info("Using cached config due to network error")
finalError = nil
if self.config?.getUserConfig() != nil {
Log.info("Using cached config due to network error")
finalError = nil
}
} else if let config = config {
Log.debug("Config: \(config)", tags: ["setup"])
self.updateUserConfig(config)
} else {
Log.error("No config returned for setup", tags: ["setup"])
finalError = ClientError.ConfigFetchFailed
}
} else if let config = config {
Log.debug("Config: \(config)", tags: ["setup"])
self.updateUserConfig(config)
} else {
Log.error("No config returned for setup", tags: ["setup"])
finalError = ClientError.ConfigFetchFailed
}

if let config = config,
self.checkIfEdgeDBEnabled(config: config, enableEdgeDB: self.enableEdgeDB)
{
if !user.isAnonymous {
self.service?.saveEntity(
user: user,
completion: { data, response, error in
if error != nil {
Log.error(
"Error saving user entity for \(user). Error: \(String(describing: error))"
)
} else {
Log.info("Saved user entity")
}
})
if let config = config {
self.syncUserToEdgeDBIfEnabled(user: user, config: config)
}
}

for handler in self.configCompletionHandlers {
handler(finalError)
}
callback?(finalError)
self.initialized = true
self.configCompletionHandlers = []
})
self.deliverInitializationComplete(error: finalError, callback: callback)
})
}

self.flushTimer = Timer.scheduledTimer(
withTimeInterval: TimeInterval(self.flushEventsInterval),
Expand All @@ -213,6 +203,20 @@ public class DevCycleClient {
}
}

private func syncUserToEdgeDBIfEnabled(user: DevCycleUser, config: UserConfig) {
guard !user.isAnonymous,
checkIfEdgeDBEnabled(config: config, enableEdgeDB: self.enableEdgeDB)
else { return }

self.service?.saveEntity(user: user) { _, _, error in
if let error = error {
Log.error("Error saving user entity for \(user). Error: \(error)")
} else {
Log.info("Saved user entity")
}
}
}

func setSDKKey(_ sdkKey: String) {
self.sdkKey = sdkKey
}
Expand All @@ -234,19 +238,83 @@ public class DevCycleClient {
guard let self = self else { return }
if let error = error {
Log.error("Error getting config: \(error)", tags: ["refetchConfig"])
if self.isNonRetryableError(error) {
self.notifyConfigUpdated(error: error)
}
} else if let config = config {
self.updateUserConfig(config)
self.notifyConfigUpdated(error: nil)
} else {
Log.error("No config returned for refetchConfig", tags: ["refetchConfig"])
}
})
}
}

private func deliverInitializationComplete(
error: Error?,
callback: ClientInitializedHandler?
) {
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
self.initialized = true
// Snapshot then clear so a handler that calls setup() again can't re-fire itself.
let handlers = self.configCompletionHandlers
self.configCompletionHandlers = []
for handler in handlers {
handler(error)
}
callback?(error)
}
}

private func performBackgroundRefresh() {
guard !self.closed, let user = self.lastIdentifiedUser else { return }
self.service?.getConfig(user: user, enableEdgeDB: self.enableEdgeDB, extraParams: nil) {
[weak self] config, error in
guard let self = self, !self.closed else { return }

guard user === self.lastIdentifiedUser else {
Log.warn(
"Background refresh result is for stale user context, ignoring",
tags: ["backgroundRefresh"])
return
}

if let error = error {
if self.isNonRetryableError(error) {
// Keep cached values usable on non-retryable errors; only TTL evicts the cache.
Log.error(
"Background refresh failed with non-retryable error, keeping cached config and notifying observers: \(error)",
tags: ["backgroundRefresh"])
self.notifyConfigUpdated(error: error)
} else {
Log.warn(
"Background refresh failed with transient error, keeping cached config: \(error)",
tags: ["backgroundRefresh"])
}
} else if let config = config {
self.updateUserConfig(config)
self.syncUserToEdgeDBIfEnabled(user: user, config: config)
self.notifyConfigUpdated(error: nil)
} else {
Log.warn(
"Background refresh returned nil config with no error",
tags: ["backgroundRefresh"])
Comment thread
luxscious marked this conversation as resolved.
}
}
}

private func isNonRetryableError(_ error: Error) -> Bool {
guard let apiError = error as? APIError else { return false }
return apiError.isNonRetryableError
}

private func updateUserConfig(_ config: UserConfig) {
let oldSSEURL = self.config?.userConfig?.sse?.url
self.config?.setUserConfig(config: config)

self.isConfigCached = false

if let newSSEURL = config.sse?.url,
self.options?.disableRealtimeUpdates != true,
oldSSEURL != newSSEURL || self.sseConnection == nil
Expand Down Expand Up @@ -442,6 +510,7 @@ public class DevCycleClient {
updateUser = user
}

let previousUser = self.lastIdentifiedUser
self.lastIdentifiedUser = user

self.service?.getConfig(
Expand All @@ -454,19 +523,28 @@ public class DevCycleClient {
"Error getting config: \(error) for user_id \(String(describing: updateUser.userId))",
tags: ["identify"])

// Try to use cached config for the new user
// If we have a cached config, proceed without error
if self.isNonRetryableError(error) {
self.cacheService.clearConfigForUser(user: updateUser)
Comment thread
luxscious marked this conversation as resolved.
self.lastIdentifiedUser = previousUser
Log.error(
"Non-retryable error on identifyUser, clearing cache and restoring previous user: \(error)",
tags: ["identify"])
callback?(error, nil)
return
}

if self.useCachedConfigForUser(user: updateUser),
self.config?.getUserConfig() != nil
{
Log.info(
"Using cached config for identifyUser due to network error: \(error)",
tags: ["identify"])
self.user = user
self.performBackgroundRefresh()
callback?(nil, self.config?.getUserConfig()?.variables)
return
} else {
// No cached config available, return error and don't change client state
self.lastIdentifiedUser = previousUser
Log.error(
"Error getting config for identifyUser: \(error)", tags: ["identify"])
callback?(error, nil)
Expand Down Expand Up @@ -509,6 +587,7 @@ public class DevCycleClient {
public func resetUser(callback: IdentifyCompletedHandler? = nil) throws {
self.flushEvents()

let previousUser = self.lastIdentifiedUser
let cachedAnonUserId = self.cacheService.getAnonUserId()
self.cacheService.clearAnonUserId()
let anonUser = try DevCycleUser.builder().isAnonymous(true).build()
Expand All @@ -522,7 +601,18 @@ public class DevCycleClient {

if let error = error {
Log.error("Error getting config for resetUser: \(error)", tags: ["reset"])
// Restore previous anonymous user ID on error and don't change client state
if self.isNonRetryableError(error) {
self.cacheService.clearConfigForUser(user: anonUser)
if let previousAnonUserId = cachedAnonUserId {
self.cacheService.setAnonUserId(anonUserId: previousAnonUserId)
}
self.lastIdentifiedUser = previousUser
Log.error(
"Definitive error on resetUser, clearing anonymous user cache and restoring previous user: \(error)",
tags: ["reset"])
callback?(error, nil)
return
}
if let previousAnonUserId = cachedAnonUserId {
self.cacheService.setAnonUserId(anonUserId: previousAnonUserId)
}
Expand Down Expand Up @@ -567,6 +657,47 @@ public class DevCycleClient {
return self.config?.getUserConfig()?.variables ?? [:]
}

/// `true` while the in-memory config is from the persisted cache and no successful refresh has replaced it yet.
public func hasUsableCachedConfig() -> Bool {
return self.config?.getUserConfig() != nil && self.isConfigCached
}

/// Invoked (main queue) on a successful refresh or a definitive error; transient errors are not delivered.
/// A refresh completing before any handler is registered is buffered and replayed once to the first registrant.
public func onConfigUpdated(_ callback: @escaping ConfigUpdatedHandler) {
var pendingError: Error?
var hasPending = false
configUpdateQueue.sync {
configUpdatedCallbacks.append(callback)
if hasPendingConfigUpdate {
pendingError = pendingConfigUpdateError
hasPending = true
hasPendingConfigUpdate = false
pendingConfigUpdateError = nil
}
}
if hasPending {
let errorToDeliver = pendingError
DispatchQueue.main.async { callback(errorToDeliver) }
}
}

private func notifyConfigUpdated(error: Error? = nil) {
var callbacksSnapshot: [ConfigUpdatedHandler] = []
configUpdateQueue.sync {
if configUpdatedCallbacks.isEmpty {
hasPendingConfigUpdate = true
pendingConfigUpdateError = error
return
}
callbacksSnapshot = configUpdatedCallbacks
}
guard !callbacksSnapshot.isEmpty else { return }
DispatchQueue.main.async {
Comment thread
luxscious marked this conversation as resolved.
for cb in callbacksSnapshot { cb(error) }
}
}

public func track(_ event: DevCycleEvent) {
if self.closed {
Log.error("DevCycleClient is closed, cannot log new events.")
Expand Down Expand Up @@ -620,6 +751,11 @@ public class DevCycleClient {
}
Log.info("Closing DevCycleClient and flushing remaining events.")
self.closed = true
configUpdateQueue.sync {
self.configUpdatedCallbacks.removeAll()
self.hasPendingConfigUpdate = false
self.pendingConfigUpdateError = nil
}
self.flushTimer?.invalidate()
self.flushEvents(callback: { error in
callback?()
Expand Down Expand Up @@ -735,9 +871,20 @@ public class DevCycleClient {
let cachedConfig = cacheService.getConfig(user: user)
{
self.config?.setUserConfig(config: cachedConfig)
self.isConfigCached = true
Log.debug("Loaded config from cache for user_id \(String(describing: user.userId))")

// Bring up SSE from the cached URL; updateUserConfig() reconnects if the refresh changes it.
if cachedConfig.sse?.url != nil,
self.options?.disableRealtimeUpdates != true,
self.sseConnection == nil
{
self.setupSSEConnection()
Comment thread
luxscious marked this conversation as resolved.
}
Comment thread
luxscious marked this conversation as resolved.

return true
}
self.isConfigCached = false
return false
}
}
Expand Down
Loading
Loading