Files
Rick 7bbbe5e622 refactor(remote-config): introduce config lifecycle observers (#7875)
* refactor(remote-config): introduce config lifecycle observers

* refactor(remote-config): simplify SDK settings delivery

* test(remote-config): cover lifecycle terminal failure
2026-10-01 13:50:58 +02:00

1197 lines
45 KiB
Swift

//
// RemoteConfigManager.swift
// RevenueCat
//
// Created by Rick van der Linden.
// Copyright © 2026 RevenueCat, Inc. All rights reserved.
import Foundation
// swiftlint:disable file_length
enum RemoteConfigReadPolicy: Equatable {
case fetchIfNeeded
case cachedOnly
}
protocol RemoteConfigManagerType: AnyObject {
/// Monotonically increases whenever committed remote config state is replaced or invalidated.
var configGeneration: Int { get }
/// Whether a remote configuration has been committed and is available to read.
func hasCommittedConfig() async -> Bool
/// Registers an observer for remote-config lifecycle events.
func addConfigLifecycleObserver(_ observer: RemoteConfigLifecycleObserver)
func refreshRemoteConfig(fetchContext: RemoteConfigFetchContext, isAppBackgrounded: Bool)
func refreshRemoteConfigIfStale(fetchContext: RemoteConfigFetchContext, isAppBackgrounded: Bool)
/// Returns the committed item index for a known topic.
///
/// If the topic is not cached, this waits for an in-flight refresh or triggers one foreground refresh before
/// reading again. Returns `nil` when the topic is still unavailable after refresh.
func topic(_ topic: RemoteConfigTopic, policy: RemoteConfigReadPolicy) async -> RemoteConfiguration.ConfigTopic?
/// Waits for the refresh currently in flight, if any, then returns the latest committed topic.
/// Unlike `topic(_:)`, this never starts a refresh.
func committedTopicAfterInFlightRefresh(_ topic: RemoteConfigTopic) async
-> RemoteConfiguration.ConfigTopic?
/// Returns the blob payload bytes for an item referenced by `blob_ref`.
///
/// Inline item metadata is exposed through `topic(_:)`; items without `blob_ref` return `nil`. Missing items
/// wait for an in-flight refresh or trigger one foreground refresh before resolving the blob on demand.
func blobData(for topic: RemoteConfigTopic, itemKey: String, policy: RemoteConfigReadPolicy) async -> Data?
/// Decodes a blob payload as a concrete `Decodable` type.
///
/// Returns `nil` when the item or blob is unavailable. Throws when bytes are available but cannot be decoded.
func blobData<T: Decodable>(
for topic: RemoteConfigTopic,
itemKey: String,
as type: T.Type
) async throws -> T?
/// Ensures every blob in `refs` has finished downloading (or failed), joining any already
/// in-flight or queued (e.g. prefetch) download for the same ref instead of starting a
/// duplicate. Returns once every ref has settled; the `Bool` reports whether all succeeded.
@discardableResult
func ensureBlobsDownloaded(_ refs: [String]) async -> Bool
/// Decodes multiple blob payloads into one keyed JSON object.
///
/// Each requested item must be backed by `blob_ref`. The merged object is keyed by item key, so item
/// `translations` contributes to the `translations` field of `T`.
func mergeItemsBlobData<T: Decodable>(
for topic: RemoteConfigTopic,
itemKeys: [String],
as type: T.Type
) async throws -> T?
/// Returns `topic`'s committed item index once every item flagged for prefetch has also finished
/// downloading its blob (or failed). Behaves like `topic(_:)` otherwise: waits for an in-flight
/// refresh or triggers one foreground refresh before reading, and returns `nil` when the topic is
/// still unavailable after refresh.
///
/// If the topic is invalidated (e.g. an identity change) while waiting on its blobs, this re-reads
/// and waits again on the new snapshot's own prefetch refs rather than returning one paired with
/// stale blob-wait results.
func awaitTopicAndPrefetchBlobsReady(_ topic: RemoteConfigTopic) async -> RemoteConfiguration.ConfigTopic?
func clearCache()
func clearCache(forAppUserID appUserID: String)
func close()
}
enum RemoteConfigConsistencyError: Error, Equatable {
case stale
}
extension RemoteConfigManagerType {
func topic(_ topic: RemoteConfigTopic) async -> RemoteConfiguration.ConfigTopic? {
return await self.topic(topic, policy: .fetchIfNeeded)
}
func blobData(for topic: RemoteConfigTopic, itemKey: String) async -> Data? {
return await self.blobData(for: topic, itemKey: itemKey, policy: .fetchIfNeeded)
}
/// Registers an observer weakly for remote-config lifecycle events.
func addConfigLifecycleObserver(_ observer: RemoteConfigLifecycleObserver) {}
func addConfigLifecycleObservers(_ observers: [any RemoteConfigLifecycleObserver]) {
for observer in observers {
self.addConfigLifecycleObserver(observer)
}
}
/// Performs a read against one config generation and retries once if a successful read was
/// superseded while suspended. Errors and cancellation are propagated immediately. An exhausted
/// stale read throws `RemoteConfigConsistencyError.stale`.
func readConsistent<Value>(
_ operation: () async throws -> Value?
) async throws -> Value? {
for attempt in 0...1 {
try Task.checkCancellation()
let generation = self.configGeneration
do {
let value = try await operation()
try Task.checkCancellation()
guard self.configGeneration == generation else {
guard attempt == 0 else {
throw RemoteConfigConsistencyError.stale
}
Logger.verbose(RemoteConfigStrings.remoteConfigReadRetry)
continue
}
return value
} catch let error as CancellationError {
throw error
} catch {
guard self.configGeneration != generation else { throw error }
guard attempt == 0 else {
throw RemoteConfigConsistencyError.stale
}
Logger.verbose(RemoteConfigStrings.remoteConfigReadRetry)
}
}
return nil
}
func withCurrentConfigGeneration<T>(_ operation: (Int) -> T?) -> T? {
let generation = self.configGeneration
guard let value = operation(generation),
self.configGeneration == generation else {
return nil
}
return value
}
func topicCacheSnapshot(_ topic: RemoteConfigTopic) async
-> GenerationGuardedCacheSnapshot<RemoteConfiguration.ConfigTopic>? {
guard let configTopic = await self.topic(topic) else { return nil }
return .init(generation: self.configGeneration, key: configTopic)
}
/// Returns a snapshot of already-committed topic metadata without waiting for or triggering a config refresh.
func committedTopicCacheSnapshot(_ topic: RemoteConfigTopic) async
-> GenerationGuardedCacheSnapshot<RemoteConfiguration.ConfigTopic>? {
guard let configTopic = await self.topic(topic, policy: .cachedOnly) else { return nil }
return .init(generation: self.configGeneration, key: configTopic)
}
func committedTopicCacheSnapshotAfterInFlightRefresh(_ topic: RemoteConfigTopic) async
-> GenerationGuardedCacheSnapshot<RemoteConfiguration.ConfigTopic>? {
guard let configTopic = await self.committedTopicAfterInFlightRefresh(topic) else { return nil }
return .init(generation: self.configGeneration, key: configTopic)
}
func isCurrent(
_ snapshot: GenerationGuardedCacheSnapshot<RemoteConfiguration.ConfigTopic>,
for topic: RemoteConfigTopic
) async -> Bool {
guard self.configGeneration == snapshot.generation,
await self.topic(topic) == snapshot.key else {
return false
}
return true
}
func awaitTopicAndPrefetchBlobsReady(_ topic: RemoteConfigTopic) async -> RemoteConfiguration.ConfigTopic? {
guard var committed = await self.topic(topic) else { return nil }
while true {
let prefetchRefs = committed.values.compactMap { $0.prefetch ? $0.blobRef : nil }
await self.ensureBlobsDownloaded(prefetchRefs)
// The topic could have been invalidated and refetched (e.g. an identity change) while
// waiting on its blobs. Re-reading and comparing catches that without needing this
// generic extension to see RemoteConfigManager's private epoch tracking.
guard let latest = await self.topic(topic) else { return nil }
guard latest != committed else { return committed }
committed = latest
}
}
func mergeItemsBlobData<T: Decodable>(
for topic: RemoteConfigTopic,
itemKeys: [String],
as type: T.Type
) async throws -> T? {
let uniqueItemKeys = itemKeys.deduplicated()
guard !uniqueItemKeys.isEmpty else {
Logger.warn(Strings.remoteConfig.mergeItemsBlobDataEmpty(topic: topic))
return nil
}
let resolvedBlobs = await withTaskGroup(of: (String, Data?).self) { group in
for itemKey in uniqueItemKeys {
group.addTask {
return (itemKey, await self.blobData(for: topic, itemKey: itemKey))
}
}
var resolvedBlobs: [String: Data?] = [:]
for await (itemKey, data) in group {
resolvedBlobs.updateValue(data, forKey: itemKey)
}
return resolvedBlobs
}
let unavailableItemKeys = uniqueItemKeys.filter { itemKey in
guard let resolvedBlob = resolvedBlobs[itemKey] else { return true }
return resolvedBlob == nil
}
guard unavailableItemKeys.isEmpty else {
Logger.warn(Strings.remoteConfig.mergeItemsBlobDataUnavailableItems(
topic: topic,
itemKeys: unavailableItemKeys
))
return nil
}
guard let mergedData = try Self.makeJSONEnvelopeData(
orderedItemKeys: uniqueItemKeys,
resolvedBlobs: resolvedBlobs
) else {
return nil
}
return try JSONDecoder.default.decode(type, from: mergedData)
}
/// Builds a keyed JSON object `{"<itemKey>":<blobBytes>,...}` from already-encoded blob payloads.
///
/// Each blob is a valid JSON value that is appended verbatim, avoiding a decode -> encode -> decode
/// cycle. Item keys keep the order of `orderedItemKeys` and are escaped via `JSONEncoder.default`.
/// Returns `nil` if any key has no resolved blob data.
private static func makeJSONEnvelopeData(
orderedItemKeys: [String],
resolvedBlobs: [String: Data?]
) throws -> Data? {
var envelope = Data("{".utf8)
for (index, itemKey) in orderedItemKeys.enumerated() {
guard let resolvedBlob = resolvedBlobs[itemKey],
let data = resolvedBlob else { return nil }
if index > 0 {
envelope.append(contentsOf: ",".utf8)
}
envelope.append(try JSONEncoder.default.encode(itemKey))
envelope.append(contentsOf: ":".utf8)
envelope.append(data)
}
envelope.append(contentsOf: "}".utf8)
return envelope
}
}
final class NoOpRemoteConfigManager: RemoteConfigManagerType {
let configGeneration = 0
func refreshRemoteConfig(fetchContext: RemoteConfigFetchContext, isAppBackgrounded: Bool) {}
func refreshRemoteConfigIfStale(fetchContext: RemoteConfigFetchContext, isAppBackgrounded: Bool) {}
func hasCommittedConfig() async -> Bool {
return false
}
func topic(_ topic: RemoteConfigTopic, policy: RemoteConfigReadPolicy) async -> RemoteConfiguration.ConfigTopic? {
return nil
}
func committedTopicAfterInFlightRefresh(_ topic: RemoteConfigTopic) async
-> RemoteConfiguration.ConfigTopic? {
return nil
}
func blobData(for topic: RemoteConfigTopic, itemKey: String, policy: RemoteConfigReadPolicy) async -> Data? {
return nil
}
func blobData<T: Decodable>(
for topic: RemoteConfigTopic,
itemKey: String,
as type: T.Type
) async throws -> T? {
return nil
}
func ensureBlobsDownloaded(_ refs: [String]) async -> Bool {
return false
}
func clearCache() {}
func clearCache(forAppUserID appUserID: String) {}
func close() {}
}
enum RemoteConfigLifecycleEvent {
case observerRegistered(generation: Int)
case committed(generation: Int)
case refreshFinished(fetchContext: RemoteConfigFetchContext, generation: Int)
}
protocol RemoteConfigLifecycleObserver: AnyObject {
func remoteConfigEventReceived(_ event: RemoteConfigLifecycleEvent)
}
/// Coordinates a single remote config refresh.
///
/// This manager currently owns only manifest replay, inline blob extraction, and config-state persistence.
// swiftlint:disable:next todo
/// TODO: Remove this interim scope once topic handler dispatch and SDK lifecycle wiring land.
final class RemoteConfigManager: RemoteConfigManagerType {
private static let defaultDomain = "app"
private static let refreshAttemptCooldownInSeconds: TimeInterval = 60
private let remoteConfigAPI: RemoteConfigAPIType
private let diskCache: RemoteConfigDiskCacheType
private let blobStore: RemoteConfigBlobStoreType
private let blobFetcher: RemoteConfigBlobFetcherType
private let currentUserProvider: CurrentUserProvider
private let dateProvider: DateProvider
private let cacheDurationInSeconds: (Bool) -> TimeInterval
/// Immutable per-request snapshot chosen under `lock`.
fileprivate struct RefreshRequestContext {
let epoch: Int
let requestAppUserID: String
let fetchContext: RemoteConfigFetchContext
}
/// Runs blocking committed-state reads away from the caller's executor.
private let readQueue = DispatchQueue(label: "com.revenuecat.remote-config.read")
/// Serializes refresh ownership, epoch checks, and cache mutations against `clearCache()` and `close()`.
private let lock = Lock()
/// Tracks the single config refresh whose completion read APIs may await.
private var isRefreshing = false
/// Forces refreshes to report `.appStart` until the session's first config is committed, regardless of the
/// caller's context, so the backend always sees `app_start` on a fresh app open. Set under `lock` only once config
/// is durably committed (persisted from a 200 or the fallback) or confirmed current (204); a failed refresh or an
/// undecodable/unpersistable 200 keeps forcing `.appStart` until a later attempt actually commits config.
private var hasCommittedInitialConfig = false
/// Teardown guard that prevents new refresh work after `close()`.
private var isClosed = false
/// Incremented when local state is invalidated so late responses from older users/sessions are dropped.
private var epoch = 0
/// Incremented whenever committed config changes or becomes invalid. Async cache warmers use this
/// as a stale-write guard so older work cannot repopulate memory after a newer config is active.
private var generation = 0
/// App user ID captured by an identity-bound cache clear.
///
/// During login/switch/logout, `clearCache` can bump the epoch before every caller observes the new user from
/// `CurrentUserProvider`. Storing the cleared identity under the same lock makes refresh preparation prefer this
/// ID over the provider value, so a request is either created for the cleared identity or treated as stale later.
private var identityBoundAppUserID: String?
/// Client-clock time when this process last completed a successful `200` or `204` refresh.
///
/// This is memory-only and is compared with `dateProvider.now()` to decide when the local cache is stale.
private var lastRefreshedAt: Date?
/// Client-clock time when this process last attempted a stale-gated refresh.
///
/// This is memory-only and drives the local retry cooldown. The configuration stores its main-server request
/// time separately for refresh requests.
private var lastRefreshAttemptAt: Date?
/// Read callers waiting for the current refresh to finish before rereading committed config state.
///
/// These continuations carry no result because callers decide what to do by rereading disk state after the
/// refresh, clear, close, or failure completes.
private var refreshContinuations: [CheckedContinuation<Void, Never>] = []
private var configLifecycleObservers: [(RemoteConfigLifecycleEvent) -> Void] = []
init(
remoteConfigAPI: RemoteConfigAPIType,
diskCache: RemoteConfigDiskCacheType,
blobStore: RemoteConfigBlobStoreType,
blobFetcher: RemoteConfigBlobFetcherType,
currentUserProvider: CurrentUserProvider,
dateProvider: DateProvider = DateProvider(),
cacheDurationInSeconds: @escaping (Bool) -> TimeInterval = { _ in 60 * 5.0 }
) {
self.remoteConfigAPI = remoteConfigAPI
self.diskCache = diskCache
self.blobStore = blobStore
self.blobFetcher = blobFetcher
self.currentUserProvider = currentUserProvider
self.dateProvider = dateProvider
self.cacheDurationInSeconds = cacheDurationInSeconds
}
var configGeneration: Int {
return self.lock.perform {
self.generation
}
}
func topic(_ topic: RemoteConfigTopic, policy: RemoteConfigReadPolicy) async -> RemoteConfiguration.ConfigTopic? {
return await self.readCommittedState(refreshIfMissing: policy == .fetchIfNeeded) {
await self.committedTopic(topic)
}
}
func blobData(for topic: RemoteConfigTopic, itemKey: String, policy: RemoteConfigReadPolicy) async -> Data? {
guard let itemSnapshot = await self.readCommittedStateSnapshot(refreshIfMissing: policy == .fetchIfNeeded, {
await self.committedTopic(topic)?[itemKey]
}), let item = itemSnapshot.value, let ref = item.blobRef else { return nil }
return await self.readCommittedState(epoch: itemSnapshot.epoch) {
policy == .cachedOnly ? await self.readBlob(ref: ref) : await self.blobData(for: item)
}
}
func addConfigLifecycleObserver(_ observer: RemoteConfigLifecycleObserver) {
self.lock.perform {
self.configLifecycleObservers.append { [weak observer] event in
observer?.remoteConfigEventReceived(event)
}
}
observer.remoteConfigEventReceived(.observerRegistered(generation: self.configGeneration))
}
func hasCommittedConfig() async -> Bool {
return await self.performRead {
self.diskCache.read() != nil
}
}
private var canReadCommittedState: Bool {
return self.lock.perform {
!self.isClosed
}
}
func refreshRemoteConfig(fetchContext: RemoteConfigFetchContext, isAppBackgrounded: Bool) {
let appUserID = self.currentUserProvider.currentAppUserID
guard let requestContext = self.prepareRefreshIfNeeded(
fetchContext: fetchContext,
appUserID: appUserID
) else { return }
self.startRefresh(isAppBackgrounded: isAppBackgrounded, requestContext: requestContext)
}
func refreshRemoteConfigIfStale(fetchContext: RemoteConfigFetchContext, isAppBackgrounded: Bool) {
let appUserID = self.currentUserProvider.currentAppUserID
guard let requestContext = self.prepareRefreshIfStale(
fetchContext: fetchContext,
isAppBackgrounded: isAppBackgrounded,
appUserID: appUserID
) else { return }
self.startRefresh(isAppBackgrounded: isAppBackgrounded, requestContext: requestContext)
}
func committedTopicAfterInFlightRefresh(_ topic: RemoteConfigTopic) async
-> RemoteConfiguration.ConfigTopic? {
_ = await self.awaitInFlightRefresh()
return await self.readCommittedState {
await self.committedTopic(topic)
}
}
func blobData<T: Decodable>(
for topic: RemoteConfigTopic,
itemKey: String,
as type: T.Type
) async throws -> T? {
guard let data = await self.blobData(for: topic, itemKey: itemKey) else { return nil }
return try JSONDecoder.default.decode(type, from: data)
}
func ensureBlobsDownloaded(_ refs: [String]) async -> Bool {
return await self.blobFetcher.ensureAllDownloaded(refs: refs)
}
/// Wipes cached remote config state, for example after an identity change.
///
/// The epoch bump, refresh-guard release, and cache wipe are serialized with response persistence so a late
/// response for a previous user is either fully persisted before the wipe or dropped after the epoch changes.
func clearCache() {
self.clearCache(forAppUserID: self.currentUserProvider.currentAppUserID)
}
func clearCache(forAppUserID appUserID: String) {
let continuations = self.lock.perform {
self.epoch += 1
self.generation += 1
self.identityBoundAppUserID = appUserID
self.isRefreshing = false
self.lastRefreshedAt = nil
self.lastRefreshAttemptAt = nil
self.diskCache.clear()
self.blobStore.clear()
return self.drainRefreshContinuations()
}
continuations.forEach { $0.resume() }
}
func close() {
let continuations = self.lock.perform {
self.epoch += 1
self.generation += 1
self.isClosed = true
self.isRefreshing = false
return self.drainRefreshContinuations()
}
continuations.forEach { $0.resume() }
}
}
private extension RemoteConfigManager {
/// Overrides a refresh's context to `.appStart` until the session's first config is committed, so the backend
/// always sees `app_start` first on a fresh app open. Must be called within `lock`.
func fetchContextForRefresh(_ requested: RemoteConfigFetchContext) -> RemoteConfigFetchContext {
return self.hasCommittedInitialConfig ? requested : .appStart
}
func prepareRefreshIfNeeded(
fetchContext: RemoteConfigFetchContext,
appUserID: String
) -> RefreshRequestContext? {
return self.lock.perform {
guard !self.isRefreshing,
!self.isClosed else { return nil }
let requestAppUserID = self.identityBoundAppUserID ?? appUserID
self.isRefreshing = true
return .init(
epoch: self.epoch,
requestAppUserID: requestAppUserID,
fetchContext: self.fetchContextForRefresh(fetchContext)
)
}
}
func prepareRefreshIfStale(
fetchContext: RemoteConfigFetchContext,
isAppBackgrounded: Bool,
appUserID: String,
expectedEpoch: Int? = nil
) -> RefreshRequestContext? {
return self.lock.perform {
guard !self.isRefreshing,
!self.isClosed else { return nil }
let now = self.dateProvider.now()
if let expectedEpoch {
guard self.epoch == expectedEpoch || self.identityBoundAppUserID != nil else { return nil }
}
if let lastRefreshedAt = self.lastRefreshedAt {
guard now.timeIntervalSince(lastRefreshedAt)
> self.cacheDurationInSeconds(isAppBackgrounded) else { return nil }
}
if let lastRefreshAttemptAt = self.lastRefreshAttemptAt {
guard now.timeIntervalSince(lastRefreshAttemptAt)
> Self.refreshAttemptCooldownInSeconds else { return nil }
}
let requestAppUserID = self.identityBoundAppUserID ?? appUserID
self.isRefreshing = true
self.lastRefreshAttemptAt = now
return .init(
epoch: self.epoch,
requestAppUserID: requestAppUserID,
fetchContext: self.fetchContextForRefresh(fetchContext)
)
}
}
func startRefresh(isAppBackgrounded: Bool, requestContext: RefreshRequestContext) {
let persisted = self.diskCache.read()
let request = RemoteConfigRequest(
fetchContext: requestContext.fetchContext,
appUserID: requestContext.requestAppUserID,
domain: persisted?.domain ?? Self.defaultDomain,
manifest: persisted?.manifest,
prefetchedBlobs: self.cachedPrefetchedBlobRefs(from: persisted),
// Replay the persisted main-server request date.
lastRefreshTime: persisted?.lastRefreshTime
)
Logger.verbose(Strings.remoteConfig.refreshing(
domain: request.domain, manifestPresent: request.manifest != nil, isAppBackgrounded: isAppBackgrounded
))
self.enqueueRefreshIfCurrent(
request: request,
persisted: persisted,
isAppBackgrounded: isAppBackgrounded,
requestContext: requestContext
)
}
func enqueueRefreshIfCurrent(
request: RemoteConfigRequest,
persisted: PersistedRemoteConfiguration?,
isAppBackgrounded: Bool,
requestContext: RefreshRequestContext
) {
// Keep the epoch check and operation enqueue atomic with clearCache(), so a clear cannot slip in between them.
// This assumes getRemoteConfig only registers/enqueues work and does not synchronously call its completion.
self.lock.perform {
guard self.epoch == requestContext.epoch else { return }
self.remoteConfigAPI.getRemoteConfig(
request: request,
isAppBackgrounded: isAppBackgrounded
) { [weak self] result in
guard let self else { return }
switch result {
case let .success(fetchResult):
self.handleSuccess(
fetchResult,
previous: persisted,
requestContext: requestContext
)
case let .failure(error):
self.handleFailure(
error,
request: request,
previous: persisted,
isAppBackgrounded: isAppBackgrounded,
requestContext: requestContext
)
}
}
}
}
@discardableResult
func releaseGuardIfOwned(requestContext: RefreshRequestContext) -> Bool {
let continuations = self.lock.perform {
guard self.epoch == requestContext.epoch else { return nil as [CheckedContinuation<Void, Never>]? }
self.isRefreshing = false
return self.drainRefreshContinuations()
}
guard let continuations else { return false }
continuations.forEach { $0.resume() }
return true
}
func finishRefresh(requestContext: RefreshRequestContext) {
guard self.releaseGuardIfOwned(requestContext: requestContext) else { return }
self.notifyConfigLifecycleObservers(
.refreshFinished(fetchContext: requestContext.fetchContext, generation: self.configGeneration)
)
}
func handleSuccess(
_ fetchResult: RemoteConfigFetchResult,
previous: PersistedRemoteConfiguration?,
requestContext: RefreshRequestContext
) {
guard self.isCurrent(requestContext.epoch) else { return }
defer { self.finishRefresh(requestContext: requestContext) }
guard let container = fetchResult.container else {
Logger.debug(Strings.remoteConfig.notModified)
self.markRefreshedIfCurrent(
requestContext.epoch,
previous: previous,
requestDate: fetchResult.requestDate
)
return
}
do {
let response = try container.configElement.withDecodedPayloadBytes { bytes in
try JSONDecoder.default.decode(
RemoteConfiguration.self,
from: Data(bytes)
)
}
let committedGeneration = self.lock.perform { () -> Int? in
guard self.epoch == requestContext.epoch else { return nil }
// Prefer this response's main-server request date, carrying forward the persisted value as needed.
let lastRefreshTime = fetchResult.requestDate ?? previous?.lastRefreshTime
let didPersist = self.persist(
container: container,
previous: previous,
response: response,
lastRefreshTime: lastRefreshTime
)
if didPersist {
self.generation += 1
self.markRefreshed(at: self.dateProvider.now())
return self.generation
}
return nil
}
if let committedGeneration {
self.notifyConfigLifecycleObservers(.committed(generation: committedGeneration))
}
} catch {
Logger.error(Strings.remoteConfig.failedToParseResponse(error))
}
}
func handleFailure(
_ error: BackendError,
request: RemoteConfigRequest,
previous: PersistedRemoteConfiguration?,
isAppBackgrounded: Bool,
requestContext: RefreshRequestContext
) {
guard error.isRemoteConfigFallbackEligible,
!self.hasUsableCachedConfig(previous, for: request.domain),
SystemInfo.proxyURL == nil else {
self.handleFinalFailure(error, requestContext: requestContext)
return
}
self.enqueueRemoteConfigFallbackIfCurrent(
domain: request.domain,
previous: previous,
isAppBackgrounded: isAppBackgrounded,
requestContext: requestContext
)
}
func hasUsableCachedConfig(
_ previous: PersistedRemoteConfiguration?,
for domain: String
) -> Bool {
return previous?.domain == domain
}
func enqueueRemoteConfigFallbackIfCurrent(
domain: String,
previous: PersistedRemoteConfiguration?,
isAppBackgrounded: Bool,
requestContext: RefreshRequestContext
) {
self.lock.perform {
guard self.epoch == requestContext.epoch else { return }
self.remoteConfigAPI.getRemoteConfigFallback(
domain: domain,
isAppBackgrounded: isAppBackgrounded
) { [weak self] result in
guard let self else { return }
switch result {
case let .success(fallbackResult):
self.handleRemoteConfigFallbackSuccess(
fallbackResult,
previous: previous,
requestContext: requestContext
)
case let .failure(fallbackError):
self.handleFinalFailure(
fallbackError,
requestContext: requestContext
)
}
}
}
}
func handleRemoteConfigFallbackSuccess(
_ fallbackResult: RemoteConfigFallbackFetchResult,
previous: PersistedRemoteConfiguration?,
requestContext: RefreshRequestContext
) {
guard self.isCurrent(requestContext.epoch) else { return }
defer { self.finishRefresh(requestContext: requestContext) }
let committedGeneration = self.lock.perform { () -> Int? in
guard self.epoch == requestContext.epoch else { return nil }
// Keep the persisted refresh time associated with main API responses.
let didPersist = self.persist(
container: nil,
previous: previous,
response: fallbackResult.configuration,
lastRefreshTime: previous?.lastRefreshTime
)
if didPersist {
self.generation += 1
self.markRefreshed(at: self.dateProvider.now())
return self.generation
}
return nil
}
if let committedGeneration {
self.notifyConfigLifecycleObservers(.committed(generation: committedGeneration))
}
}
func handleFinalFailure(
_ error: BackendError,
requestContext: RefreshRequestContext
) {
guard self.isCurrent(requestContext.epoch) else { return }
self.finishRefresh(requestContext: requestContext)
Logger.error(Strings.remoteConfig.refreshFailed(error))
}
func isCurrent(_ requestEpoch: Int) -> Bool {
return self.lock.perform {
self.epoch == requestEpoch
}
}
func markRefreshedIfCurrent(
_ requestEpoch: Int,
previous: PersistedRemoteConfiguration?,
requestDate: Date?
) {
self.lock.perform {
guard self.epoch == requestEpoch else { return }
// A response request date advances the persisted main-server time.
if let previous, let requestDate {
self.diskCache.write(previous.withLastRefreshTime(requestDate))
}
// Local staleness starts when this response lands, independently of the server timestamp above.
self.markRefreshed(at: self.dateProvider.now())
}
}
/// Records a landed refresh using the client clock and marks the session's initial config as committed, so later
/// refreshes stop being forced to `.appStart`. This timestamp drives local staleness; the main-server request
/// timestamp is persisted separately. Must be called within `lock`.
func markRefreshed(at date: Date) {
self.hasCommittedInitialConfig = true
self.lastRefreshedAt = date
self.lastRefreshAttemptAt = nil
}
/// Waits for committed config state to become available for read APIs.
///
/// A read first joins existing refresh work. If no refresh is in flight and remote config is still readable,
/// it starts one foreground refresh only when stale and waits for that attempt before the caller rereads
/// disk state. When an epoch is supplied, the wait/refresh is skipped if cache state changed since the
/// caller's initial committed read.
func awaitConfigForRead(expectedEpoch: Int? = nil) async {
if let expectedEpoch, !self.isReadable(epoch: expectedEpoch) {
return
}
if await self.awaitInFlightRefresh() {
return
}
if let expectedEpoch {
guard self.isReadable(epoch: expectedEpoch) else { return }
} else {
guard self.canReadCommittedState else { return }
}
let appUserID = self.currentUserProvider.currentAppUserID
if let requestContext = self.prepareRefreshIfStale(
fetchContext: .read,
isAppBackgrounded: false,
appUserID: appUserID,
expectedEpoch: expectedEpoch
) {
self.startRefresh(isAppBackgrounded: false, requestContext: requestContext)
}
_ = await self.awaitInFlightRefresh()
}
/// Joins the current refresh if one is active.
///
/// Returns `true` only when the caller actually waited on refresh completion. Returning `false` lets callers
/// decide whether to start a new refresh attempt.
func awaitInFlightRefresh() async -> Bool {
var didRegisterWaiter = false
await withCheckedContinuation { continuation in
didRegisterWaiter = self.lock.perform {
guard self.isRefreshing else { return false }
self.refreshContinuations.append(continuation)
return true
}
if !didRegisterWaiter {
continuation.resume()
}
}
return didRegisterWaiter
}
/// Removes and returns all read waiters so they can be resumed outside the lock.
func drainRefreshContinuations() -> [CheckedContinuation<Void, Never>] {
defer { self.refreshContinuations = [] }
return self.refreshContinuations
}
/// Reads committed state only if the manager remains on the same epoch for the whole operation.
///
/// If the value is missing, callers may opt into the read-facade behavior of awaiting or triggering one
/// foreground refresh before reading again. If `clearCache()` or `close()` happens during either read,
/// the result is discarded.
func readCommittedState<T>(
refreshIfMissing: Bool = false,
_ operation: () async -> T?
) async -> T? {
return await self.readCommittedStateSnapshot(refreshIfMissing: refreshIfMissing, operation)?.value
}
func readCommittedState<T>(
epoch: Int,
_ operation: () async -> T?
) async -> T? {
return await self.readCommittedStateSnapshot(epoch: epoch, operation)?.value
}
func readCommittedStateSnapshot<T>(
refreshIfMissing: Bool = false,
_ operation: () async -> T?
) async -> (value: T?, epoch: Int)? {
guard let result = await self.readCurrentCommittedState(operation) else { return nil }
if result.value != nil || !refreshIfMissing {
return result
}
await self.awaitConfigForRead(expectedEpoch: result.epoch)
guard self.isReadable(epoch: result.epoch) else { return nil }
return await self.readCommittedStateSnapshot(epoch: result.epoch, operation)
}
func readCurrentCommittedState<T>(_ operation: () async -> T?) async -> (value: T?, epoch: Int)? {
guard let epoch = self.currentReadableEpoch() else { return nil }
return await self.readCommittedStateSnapshot(epoch: epoch, operation)
}
func readCommittedStateSnapshot<T>(
epoch: Int,
_ operation: () async -> T?
) async -> (value: T?, epoch: Int)? {
guard self.isReadable(epoch: epoch) else { return nil }
let value = await operation()
return self.isReadable(epoch: epoch) ? (value, epoch) : nil
}
func currentReadableEpoch() -> Int? {
return self.lock.perform {
guard !self.isClosed else { return nil }
return self.epoch
}
}
func isReadable(epoch: Int) -> Bool {
return self.lock.perform {
!self.isClosed && self.epoch == epoch
}
}
/// Reads committed topic metadata off the caller's executor.
func committedTopic(_ topic: RemoteConfigTopic) async -> RemoteConfiguration.ConfigTopic? {
return await self.performRead {
self.diskCache.topic(topic)
}
}
func notifyConfigLifecycleObservers(_ event: RemoteConfigLifecycleEvent) {
let observers = self.lock.perform { self.configLifecycleObservers }
observers.forEach { $0(event) }
}
/// Resolves an external blob item through the high-priority fetch path.
///
/// Inline item metadata is returned by `topic(_:)`; this method only returns bytes for items backed by
/// `blob_ref`.
func blobData(for item: RemoteConfiguration.ConfigItem) async -> Data? {
guard let ref = item.blobRef else { return nil }
guard await self.blobFetcher.ensureDownloaded(ref: ref) else { return nil }
return await self.readBlob(ref: ref)
}
/// Reads committed blob bytes off the caller's executor.
func readBlob(ref: String) async -> Data? {
return await self.performRead {
self.blobStore.read(ref: ref)
}
}
/// Performs small blocking cache reads on the manager's read queue.
func performRead<T>(_ operation: @escaping () -> T) async -> T {
let operation = SendableReadOperation(run: operation)
return await withCheckedContinuation { continuation in
self.readQueue.async {
continuation.resume(returning: operation.run())
}
}
}
/// Replays only requested prefetch blobs that are still present in the blob store.
func cachedPrefetchedBlobRefs(from persisted: PersistedRemoteConfiguration?) -> [String] {
guard let persisted else { return [] }
let cachedRefs = self.blobStore.cachedRefs()
return persisted.prefetchBlobs.filter { cachedRefs.contains($0) }
}
/// Persists the config sync state and any valid inline blobs from a successful container response.
func persist(
container: RemoteConfigContainer?,
previous: PersistedRemoteConfiguration?,
response: RemoteConfiguration,
lastRefreshTime: Date?
) -> Bool {
Logger.debug(Strings.remoteConfig.receivedConfiguration(
activeTopics: response.activeTopics, changedTopics: Array(response.topics.entries.keys)
))
let postSyncTopics = self.postSyncTopics(
previous: previous,
response: response
)
let postSyncReferencedBlobRefs = self.postSyncReferencedBlobRefs(
response: response,
postSyncTopics: postSyncTopics
)
let persistedConfiguration = PersistedRemoteConfiguration(
domain: response.domain,
manifest: response.manifest,
activeTopics: response.activeTopics,
prefetchBlobs: response.prefetchBlobs,
topics: postSyncTopics,
lastRefreshTimeMilliseconds: lastRefreshTime?.millisecondsSince1970
)
guard self.diskCache.write(persistedConfiguration) else { return false }
if let container {
self.extractInlineBlobs(from: container, keepingOnly: postSyncReferencedBlobRefs)
}
self.blobStore.retainOnly(postSyncReferencedBlobRefs)
Logger.debug(Strings.remoteConfig.persistedConfiguration(
domain: response.domain,
activeTopicCount: response.activeTopics.count,
referencedBlobCount: postSyncReferencedBlobRefs.count
))
let refsToPrefetch = self.postSyncPrefetchBlobRefs(
response: response,
postSyncTopics: postSyncTopics
).filter { !self.blobStore.contains(ref: $0) }
Logger.verbose(Strings.remoteConfig.prefetchingBlobCount(refsToPrefetch.count))
self.blobFetcher.prefetch(refs: refsToPrefetch)
return true
}
/// Returns the full topic index that should be persisted after this response is applied.
///
/// Changed topics overwrite previous entries, unchanged active topics keep previous entries, and inactive topics
/// are removed. Changed topics are full replacements, not item-level patches, so removed items fall out of
/// the persisted topic index and blob retention set.
func postSyncTopics(
previous: PersistedRemoteConfiguration?,
response: RemoteConfiguration
) -> RemoteConfiguration.Topics {
let entries = (previous?.topics.entries ?? [:])
.merging(response.topics.entries) { _, changed in changed }
.filter { topic, _ in response.activeTopics.contains(topic) }
return RemoteConfiguration.Topics(entries: entries)
}
/// Returns the post-sync set of blob refs the SDK should keep locally.
///
/// This includes requested prefetch blobs plus blob refs used by active topics after merging changed
/// response topics with previously persisted unchanged topics.
func postSyncReferencedBlobRefs(
response: RemoteConfiguration,
postSyncTopics: RemoteConfiguration.Topics
) -> Set<String> {
return Set(response.prefetchBlobs).union(postSyncTopics.blobRefs)
}
/// Returns the post-sync blob refs the SDK should proactively warm.
///
/// This includes server-requested prefetch blobs plus any active-topic item whose metadata has
/// `prefetch: true`.
func postSyncPrefetchBlobRefs(
response: RemoteConfiguration,
postSyncTopics: RemoteConfiguration.Topics
) -> [String] {
let itemPrefetchBlobRefs = postSyncTopics.entries.values.flatMap { topic in
topic.values
.filter(\.prefetch)
.compactMap(\.blobRef)
}
return (response.prefetchBlobs + itemPrefetchBlobRefs).deduplicated()
}
/// Writes valid inline content elements that are referenced by this config response.
///
/// Invalid or unreferenced content elements are skipped because inline blobs are opportunistic cache entries.
func extractInlineBlobs(
from container: RemoteConfigContainer,
keepingOnly referencedBlobRefs: Set<String>
) {
for (ref, element) in container.inlineContentElements {
guard referencedBlobRefs.contains(ref) else { continue }
do {
try element.withDecodedPayloadBytes { bytes in
guard RemoteConfigBlobRefHelpers.isValidPayload(bytes, expectedRef: ref) else {
throw RCContainer.Parser.FormatError.checksumMismatch(
expected: ref,
actual: RemoteConfigBlobRefHelpers.ref(for: bytes)
)
}
if self.blobStore.write(ref: ref, bytes: bytes) {
Logger.verbose(Strings.remoteConfig.storedInlineBlob(ref, byteCount: bytes.count))
}
}
} catch {
Logger.error(Strings.remoteConfig.skippingInvalidBlob(ref))
}
}
}
}
private struct SendableReadOperation<T>: @unchecked Sendable {
let run: () -> T
}
private extension BackendError {
/// Failures that can retry against a fallback host are eligible for the JSON fallback config request.
var isRemoteConfigFallbackEligible: Bool {
guard case let .networkError(networkError) = self else {
return false
}
return networkError.isAllowedToRetryWithFallbackHost
}
}