mirror of
https://github.com/RevenueCat/purchases-ios
synced 2026-11-11 22:55:55 +05:45
* feat(remote-config): add RemoteConfigSourceProvider for source failover Adds RemoteConfigSourceProvider, which orders config sources via WeightedSourceSelector and hands out the current healthy endpoint, falling back to the next one when an endpoint is reported unhealthy. A per-handout UUID token discards stale unhealthy reports so a single failing endpoint can't advance the order more than once. Not yet wired into networking. Co-authored-by: Cursor <cursoragent@cursor.com> * docs(remote-config): trim RemoteConfigSourceProvider comments Co-authored-by: Cursor <cursoragent@cursor.com> * refactor(remote-config): make RemoteConfigSourceProvider an api/blob address book Take a RemoteConfigSources object (api + blob), dedup each list by url, and fail over the two kinds independently via a private SourceFailover wrapping WeightedSourceSelector. Use the endpoint url as identity (dropping the UUID token) so stale/concurrent reports are ignored, and build endpoints once during dedup so reads never re-map. Add RemoteConfigSourceProviderType and MockRemoteConfigSourceProvider for downstream mocking. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor(remote-config): refine RemoteConfigSourceProvider endpoint model Embed RemoteConfigSource in RemoteConfigEndpoint (forwarding url/priority/ weight) so endpoints are built once and never re-mapped, and compare endpoints by kind+url. Make restart() per-kind, drop unused WeightedSource/Equatable conformances and default inits, and collapse duplicate source urls to the highest-priority occurrence (tie-broken by weight), logging conflicts. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor(remote-config): drop redundant init and Equatable conformance Rely on the synthesized memberwise init for RemoteConfigSources and remove the unused Equatable conformance on RemoteConfigEndpoint (tests compare urls). Co-authored-by: Cursor <cursoragent@cursor.com> * refactor(remote-config): clarify source handle naming and strengthen concurrency test Rename RemoteConfigEndpoint to RemoteConfigSourceHandle and its Kind enum to Purpose, expose currentAPISource/currentBlobSource, and use a named parameter in restart(for:). Also rewrite the concurrent test to deterministically assert that many threads reporting the same source advance the provider exactly once. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor(remote-config): simplify RemoteConfigSourceProvider API Drop the RemoteConfigSources wrapper in favor of apiSources/blobSources init params, and replace currentAPISource/currentBlobSource with a single getCurrent(for:) keyed by purpose, consistent with restart(for:). Co-authored-by: Cursor <cursoragent@cursor.com> * refactor(remote-config): log conflicting duplicate sources as a warning Conflicting priority/weight on a shared URL signals misconfigured input, a degraded state better surfaced via Logger.warn than Logger.debug. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(remote-config): ignore stale unhealthy reports across restart Identify each handout with an opaque monotonic token that bumps on every fallback and on restart, instead of using the source url. reportUnhealthy only advances when the reported handle still carries the current token, so reports left over from before a restart can no longer skip a source in the new cycle. The token also subsumes the previous url-based dedup of stale or concurrent reports within a cycle. RemoteConfigSource now drives WeightedSourceSelector (it is the WeightedSource), and SourceFailover stamps the current token onto each handle at handout time. The handle is reduced to purpose + source + token (+ url accessor) and its init is fileprivate so handles can only originate from getCurrent. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
173 lines
6.1 KiB
Swift
173 lines
6.1 KiB
Swift
//
|
|
// RemoteConfigSourceProvider.swift
|
|
// RevenueCat
|
|
//
|
|
// Created by Antonio Pallares on 26/06/2026.
|
|
// Copyright © 2026 RevenueCat, Inc. All rights reserved.
|
|
|
|
import Foundation
|
|
|
|
/// A remote config source: a URL plus the metadata used to order sources.
|
|
struct RemoteConfigSource: WeightedSource {
|
|
|
|
/// A plain URL or a URL format with placeholders (e.g. `{blob_ref}`), to be resolved by the caller.
|
|
let url: String
|
|
let priority: Int
|
|
let weight: Int
|
|
|
|
}
|
|
|
|
/// A source handed out by a `RemoteConfigSourceProvider`, tagged with its purpose (api or blob).
|
|
/// Report it back via `reportUnhealthy(_:)` to fall back to the next source. The opaque `token`
|
|
/// is its identity: a report is ignored once the provider has moved past it (via fallback or
|
|
/// `restart(for:)`), so stale or concurrent reports can't advance the order more than once.
|
|
struct RemoteConfigSourceHandle {
|
|
|
|
/// What the source is used for: calling the config api or downloading a blob.
|
|
enum Purpose {
|
|
case api
|
|
case blob
|
|
}
|
|
|
|
let purpose: Purpose
|
|
let source: RemoteConfigSource
|
|
|
|
/// Identifies the handout that produced this handle. Opaque to callers; only meaningful to the
|
|
/// provider, which uses it to discard stale reports.
|
|
fileprivate let token: Int
|
|
|
|
/// A plain URL or a URL format with placeholders, to be resolved by the caller.
|
|
var url: String { self.source.url }
|
|
|
|
fileprivate init(purpose: Purpose, source: RemoteConfigSource, token: Int) {
|
|
self.purpose = purpose
|
|
self.source = source
|
|
self.token = token
|
|
}
|
|
|
|
}
|
|
|
|
protocol RemoteConfigSourceProviderType: AnyObject {
|
|
|
|
/// The current healthy source for `purpose`, or `nil` once all of its sources are reported unhealthy.
|
|
func getCurrent(for purpose: RemoteConfigSourceHandle.Purpose) -> RemoteConfigSourceHandle?
|
|
|
|
/// Falls back to the next source for the handle's purpose. No-op if `handle` is no longer current.
|
|
func reportUnhealthy(_ handle: RemoteConfigSourceHandle)
|
|
|
|
/// Rewinds the given purpose to its first source, e.g. to start fresh on a new fetch cycle.
|
|
func restart(for purpose: RemoteConfigSourceHandle.Purpose)
|
|
|
|
}
|
|
|
|
/// The address book for remote config: hands out the current healthy api and blob sources and
|
|
/// falls back to the next one when a source is reported unhealthy. Each purpose fails over
|
|
/// independently. Sources are deduped by url and ordered once via `WeightedSourceSelector`.
|
|
///
|
|
/// - Note: Thread-safe.
|
|
final class RemoteConfigSourceProvider: RemoteConfigSourceProviderType {
|
|
|
|
private let api: SourceFailover
|
|
private let blob: SourceFailover
|
|
|
|
init(
|
|
apiSources: [RemoteConfigSource],
|
|
blobSources: [RemoteConfigSource],
|
|
randomizer: WeightedSourceRandomizer? = nil
|
|
) {
|
|
self.api = SourceFailover(purpose: .api, sources: Self.dedupe(apiSources), randomizer: randomizer)
|
|
self.blob = SourceFailover(purpose: .blob, sources: Self.dedupe(blobSources), randomizer: randomizer)
|
|
}
|
|
|
|
func getCurrent(for purpose: RemoteConfigSourceHandle.Purpose) -> RemoteConfigSourceHandle? {
|
|
switch purpose {
|
|
case .api: return self.api.current
|
|
case .blob: return self.blob.current
|
|
}
|
|
}
|
|
|
|
func reportUnhealthy(_ handle: RemoteConfigSourceHandle) {
|
|
switch handle.purpose {
|
|
case .api: self.api.reportUnhealthy(handle)
|
|
case .blob: self.blob.reportUnhealthy(handle)
|
|
}
|
|
}
|
|
|
|
func restart(for purpose: RemoteConfigSourceHandle.Purpose) {
|
|
switch purpose {
|
|
case .api: self.api.restart()
|
|
case .blob: self.blob.restart()
|
|
}
|
|
}
|
|
|
|
/// Collapses duplicate urls to the occurrence with the highest priority (tie-broken by weight),
|
|
/// keeping first-seen order. Done once at init so reads never need to re-dedupe.
|
|
private static func dedupe(_ sources: [RemoteConfigSource]) -> [RemoteConfigSource] {
|
|
var bestByURL: [String: RemoteConfigSource] = [:]
|
|
var order: [String] = []
|
|
for source in sources {
|
|
guard let existing = bestByURL[source.url] else {
|
|
bestByURL[source.url] = source
|
|
order.append(source.url)
|
|
continue
|
|
}
|
|
if source.priority != existing.priority || source.weight != existing.weight {
|
|
Logger.warn(Strings.remoteConfig.duplicateSourceURL(source.url))
|
|
}
|
|
if source.priority > existing.priority ||
|
|
(source.priority == existing.priority && source.weight > existing.weight) {
|
|
bestByURL[source.url] = source
|
|
}
|
|
}
|
|
return order.compactMap { bestByURL[$0] }
|
|
}
|
|
|
|
}
|
|
|
|
/// Walks a single list of sources in fallback order. Every handout is stamped with the current
|
|
/// `token`, which is bumped whenever the position changes (fallback or `restart()`). A report only
|
|
/// advances if its handle still carries the current token, so stale or concurrent reports - and
|
|
/// reports left over from before a `restart()` - are ignored.
|
|
///
|
|
/// - Note: Thread-safe.
|
|
private final class SourceFailover {
|
|
|
|
private let purpose: RemoteConfigSourceHandle.Purpose
|
|
private let selector: WeightedSourceSelector<RemoteConfigSource>
|
|
private let lock = Lock()
|
|
private var token = 0
|
|
|
|
init(
|
|
purpose: RemoteConfigSourceHandle.Purpose,
|
|
sources: [RemoteConfigSource],
|
|
randomizer: WeightedSourceRandomizer?
|
|
) {
|
|
self.purpose = purpose
|
|
self.selector = WeightedSourceSelector(sources: sources, randomizer: randomizer)
|
|
}
|
|
|
|
var current: RemoteConfigSourceHandle? {
|
|
return self.lock.perform {
|
|
self.selector.current.map {
|
|
RemoteConfigSourceHandle(purpose: self.purpose, source: $0, token: self.token)
|
|
}
|
|
}
|
|
}
|
|
|
|
func reportUnhealthy(_ handle: RemoteConfigSourceHandle) {
|
|
self.lock.perform {
|
|
guard handle.token == self.token else { return }
|
|
self.selector.advance()
|
|
self.token += 1
|
|
}
|
|
}
|
|
|
|
func restart() {
|
|
self.lock.perform {
|
|
self.selector.reset()
|
|
self.token += 1
|
|
}
|
|
}
|
|
|
|
}
|