Files
purchases-ios/Sources/Networking/RemoteConfigBlobStore.swift
T
Rick f320e95384 fix(remote-config): rearm exhausted blob sources + in memory knownRefs (#7120)
* fix(remote-config): rearm exhausted blob sources

* refactor(remote-config): clarify blob prefetch filtering

* fix(remote-config): validate cached blob refs on contains

* fix(remote-config): retry blob ref scans after failures
2026-07-03 08:22:14 +02:00

253 lines
7.2 KiB
Swift

//
// RemoteConfigBlobStore.swift
// RevenueCat
//
// Created by Rick van der Linden.
// Copyright © 2026 RevenueCat, Inc. All rights reserved.
import Foundation
protocol RemoteConfigBlobStoreType: AnyObject {
func contains(ref: String) -> Bool
func read(ref: String) -> Data?
@discardableResult
func write(
ref: String,
bytes: UnsafeRawBufferPointer
) -> Bool
func cachedRefs() -> Set<String>
func retainOnly(_ refs: Set<String>)
func clear()
}
/// Content-addressed disk cache for remote config blobs, keyed by 32-character URL-safe base64 refs.
final class RemoteConfigBlobStore: RemoteConfigBlobStoreType {
private let fileManager: FileManager
private let directoryURL: URL?
private let lock = Lock(.nonRecursive)
/// Refs known to be on disk. Loaded once from a disk scan, then kept in sync by writes, pruning, and clear.
/// If disk and index ever diverge, `read(ref:)` evicts stale refs on a miss so later fetches can recover.
private var knownRefs: Set<String>?
init(
fileManager: FileManager = .default,
directoryURL: URL? = RemoteConfigBlobStore.defaultDirectoryURL
) {
self.fileManager = fileManager
self.directoryURL = directoryURL
}
func contains(ref: String) -> Bool {
return self.lock.perform {
return self.containsWithoutLock(ref: ref)
}
}
func read(ref: String) -> Data? {
return self.lock.perform {
return self.readWithoutLock(ref: ref)
}
}
@discardableResult
func write(
ref: String,
bytes: UnsafeRawBufferPointer
) -> Bool {
return self.lock.perform {
return self.writeWithoutLock(ref: ref, bytes: bytes)
}
}
func cachedRefs() -> Set<String> {
return self.lock.perform {
return self.loadedRefsWithoutLock()
}
}
func retainOnly(_ refs: Set<String>) {
self.lock.perform {
self.retainOnlyWithoutLock(refs)
}
}
func clear() {
self.lock.perform {
self.clearWithoutLock()
}
}
}
private extension RemoteConfigBlobStore {
func containsWithoutLock(ref: String) -> Bool {
guard self.loadedRefsWithoutLock().contains(ref),
let fileURL = self.fileURL(for: ref),
self.isRegularFile(fileURL) else {
self.knownRefs?.remove(ref)
return false
}
return true
}
func readWithoutLock(ref: String) -> Data? {
guard let fileURL = self.fileURL(for: ref),
self.isRegularFile(fileURL) else {
self.knownRefs?.remove(ref)
return nil
}
do {
return try Data(contentsOf: fileURL)
} catch {
Logger.error(Strings.remoteConfig.failedToReadBlob(ref, error))
return nil
}
}
func writeWithoutLock(
ref: String,
bytes: UnsafeRawBufferPointer
) -> Bool {
guard let directoryURL = self.directoryURL else {
Logger.error(Strings.remoteConfig.cacheURLNotAvailable)
return false
}
guard let fileURL = self.fileURL(for: ref) else {
Logger.error(Strings.remoteConfig.malformedBlobRef(ref))
return false
}
do {
try self.fileManager.createDirectory(
at: directoryURL,
withIntermediateDirectories: true,
attributes: nil
)
var data = Data()
data.append(contentsOf: bytes.bindMemory(to: UInt8.self))
try data.write(to: fileURL, options: .atomic)
guard var refs = self.knownRefs else {
return true
}
refs.insert(ref)
self.knownRefs = refs
return true
} catch {
Logger.error(Strings.remoteConfig.failedToWriteBlob(ref, error))
return false
}
}
func retainOnlyWithoutLock(_ refs: Set<String>) {
let validRefs = refs.filter(RemoteConfigBlobRefHelpers.isValid)
refs.subtracting(validRefs).forEach { Logger.error(Strings.remoteConfig.malformedBlobRef($0)) }
guard let directoryURL = self.directoryURL else {
self.knownRefs = []
return
}
guard let contents = try? self.fileManager.contentsOfDirectory(
at: directoryURL,
includingPropertiesForKeys: [.isRegularFileKey],
options: []
) else {
self.knownRefs = nil
return
}
for fileURL in contents where self.isRegularFile(fileURL) && !validRefs.contains(fileURL.lastPathComponent) {
do {
try self.fileManager.removeItem(at: fileURL)
} catch {
Logger.error(Strings.remoteConfig.failedToDeleteBlob(fileURL.lastPathComponent, error))
}
}
self.knownRefs = self.scannedRefsWithoutLock()?.intersection(validRefs)
}
func clearWithoutLock() {
guard let directoryURL = self.directoryURL,
self.fileManager.fileExists(atPath: directoryURL.path) else {
self.knownRefs = []
return
}
do {
try self.fileManager.removeItem(at: directoryURL)
self.knownRefs = []
} catch {
Logger.error(Strings.remoteConfig.failedToClearBlobStore(error))
self.knownRefs = nil
}
}
static let blobsDirectoryName = "blobs"
static var defaultDirectoryURL: URL? {
return DirectoryHelper.baseUrl(for: RemoteConfigDiskCache.directoryType)?
.appendingPathComponent(RemoteConfigDiskCache.basePath, isDirectory: true)
.appendingPathComponent(Self.blobsDirectoryName, isDirectory: true)
}
func fileURL(for ref: String) -> URL? {
guard RemoteConfigBlobRefHelpers.isValid(ref) else {
return nil
}
return self.directoryURL?.appendingPathComponent(ref, isDirectory: false)
}
func isRegularFile(_ fileURL: URL) -> Bool {
return (try? fileURL.resourceValues(forKeys: [.isRegularFileKey]).isRegularFile) == true
}
func loadedRefsWithoutLock() -> Set<String> {
if let knownRefs {
return knownRefs
}
guard self.directoryURL != nil else {
self.knownRefs = []
return []
}
guard let scannedRefs = self.scannedRefsWithoutLock() else {
return []
}
self.knownRefs = scannedRefs
return scannedRefs
}
func scannedRefsWithoutLock() -> Set<String>? {
guard let directoryURL = self.directoryURL,
let contents = try? self.fileManager.contentsOfDirectory(
at: directoryURL,
includingPropertiesForKeys: [.isRegularFileKey],
options: []
) else {
return nil
}
let scannedRefs = contents.reduce(into: Set<String>()) { refs, fileURL in
guard self.isRegularFile(fileURL),
RemoteConfigBlobRefHelpers.isValid(fileURL.lastPathComponent) else {
return
}
refs.insert(fileURL.lastPathComponent)
}
return scannedRefs
}
}