Files
purchases-ios/Sources/Networking/RemoteConfigDiskCache.swift
T
Antonio PallaresandCursor 4436c59d92 perf(remote-config): cache persisted config in memory in RemoteConfigDiskCache (#7136)
* perf(remote-config): cache persisted config in memory on RemoteConfigDiskCache

Keep an in-memory copy of the persisted RemoteConfiguration so read() and
topic(_:) don't hit disk on every call. It's reset to the new value on write
and cleared on clear(), and lazily loaded from disk on the next read.

Co-authored-by: Cursor <cursoragent@cursor.com>

* refactor(remote-config): distinguish not-loaded from empty with CacheState enum

Use a CacheState enum (notLoaded / loaded) so read() only hits disk once,
including when nothing is persisted, instead of re-reading on every call.

Co-authored-by: Cursor <cursoragent@cursor.com>

* refactor(remote-config): name the in-memory cache `snapshot` to match Android

Aligns naming with purchases-android's RemoteConfigDiskCache in-memory snapshot.

Co-authored-by: Cursor <cursoragent@cursor.com>

* test(remote-config): cover snapshot caching of read misses

Verify a decoding failure and a missing file both cache the miss, so the
disk is only read once even if the persisted file changes afterwards.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(remote-config): only update in-memory snapshot when write succeeds

Avoid serving a configuration from memory that failed to persist to disk.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(remote-config): update disk and snapshot atomically in write/clear

Perform the disk mutation and snapshot update under the snapshot lock so
concurrent reads can't observe a torn state (disk changed but snapshot stale),
and keep a consistent lock order (snapshot then cache) across read/write/clear.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-06 09:47:59 +02:00

167 lines
4.7 KiB
Swift

//
// RemoteConfigDiskCache.swift
// RevenueCat
//
// Created by Rick van der Linden.
// Copyright © 2026 RevenueCat, Inc. All rights reserved.
import Foundation
/// Read-only access to a topic's persisted item index (metadata only, no blob bytes or waiting).
protocol RemoteConfigTopicStoreType: AnyObject {
/// The saved items for `topic`, or `nil` when nothing has been persisted yet.
func topic(_ topic: RemoteConfigTopic) -> RemoteConfiguration.ConfigTopic?
}
protocol RemoteConfigDiskCacheType: RemoteConfigTopicStoreType {
func read() -> PersistedRemoteConfiguration?
@discardableResult
func write(_ configuration: PersistedRemoteConfiguration) -> Bool
func clear()
}
struct PersistedRemoteConfiguration: Codable, Equatable {
let domain: String
let manifest: String
let activeTopics: [String]
let prefetchBlobs: [String]
let topics: RemoteConfiguration.Topics
init(
domain: String = RemoteConfiguration.defaultDomain,
manifest: String,
activeTopics: [String] = [],
prefetchBlobs: [String] = [],
topics: RemoteConfiguration.Topics = .init()
) {
self.domain = domain
self.manifest = manifest
self.activeTopics = activeTopics
self.prefetchBlobs = prefetchBlobs
self.topics = topics
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.init(
domain: try container.decodeIfPresent(String.self, forKey: .domain) ?? RemoteConfiguration.defaultDomain,
manifest: try container.decode(String.self, forKey: .manifest),
activeTopics: try container.decodeIfPresent([String].self, forKey: .activeTopics) ?? [],
prefetchBlobs: try container.decodeIfPresent([String].self, forKey: .prefetchBlobs) ?? [],
topics: try container.decodeIfPresent(RemoteConfiguration.Topics.self, forKey: .topics) ?? .init()
)
}
private enum CodingKeys: String, CodingKey {
case domain
case manifest
case activeTopics
case prefetchBlobs
case topics
}
}
final class RemoteConfigDiskCache: RemoteConfigDiskCacheType {
private let cache: SynchronizedLargeItemCache
private let snapshot: Atomic<Snapshot> = .init(.notLoaded)
init(
cache: SynchronizedLargeItemCache = .init(
cache: FileManager.default,
basePath: RemoteConfigDiskCache.basePath,
directoryType: RemoteConfigDiskCache.directoryType
)
) {
self.cache = cache
}
func read() -> PersistedRemoteConfiguration? {
return self.snapshot.modify { snapshot in
switch snapshot {
case .notLoaded:
let configuration = self.readFromDisk()
snapshot = .loaded(configuration)
return configuration
case .loaded(let configuration):
return configuration
}
}
}
@discardableResult
func write(_ configuration: PersistedRemoteConfiguration) -> Bool {
return self.snapshot.modify { snapshot in
let didWrite = self.cache.set(codable: configuration, forKey: Self.fileName)
if didWrite {
snapshot = .loaded(configuration)
} else {
Logger.error(Strings.remoteConfig.failedToWriteCache)
}
return didWrite
}
}
func clear() {
self.snapshot.modify { snapshot in
self.cache.clear()
snapshot = .loaded(nil)
}
}
private func readFromDisk() -> PersistedRemoteConfiguration? {
do {
return try self.cache.value(forKey: Self.fileName)
} catch {
Logger.error(Strings.remoteConfig.failedToReadCache(error))
return nil
}
}
}
extension RemoteConfigDiskCache {
/// In-memory snapshot of the persisted configuration, so `read()` only hits disk once.
/// `.loaded(nil)` means we've already checked and nothing is persisted, avoiding repeated
/// disk reads while no configuration exists.
private enum Snapshot {
case notLoaded
case loaded(PersistedRemoteConfiguration?)
}
}
extension RemoteConfigDiskCache: RemoteConfigTopicStoreType {
func topic(_ topic: RemoteConfigTopic) -> RemoteConfiguration.ConfigTopic? {
return self.read()?.topics.entries[topic.wireName]
}
}
extension RemoteConfigDiskCache {
static let basePath = "remote_config"
static let fileName = "remote_config.json"
static var directoryType: DirectoryHelper.DirectoryType {
#if os(tvOS)
return .cache
#else
return .applicationSupport()
#endif
}
}