Skip to content

Commit

Permalink
Paywalls: avoid warming up cache multiple times (#3068)
Browse files Browse the repository at this point in the history
This prevents us from warming up the cache twice on app launch (because
of the explicit call in `Purchases.init` +
`applicationWillEnterForeground`).

I turned `PaywallCacheWarming` into an `actor` to simplify thread-safety
of the internal state.
  • Loading branch information
NachoSoto committed Sep 1, 2023
1 parent d6fd649 commit 12672a7
Show file tree
Hide file tree
Showing 6 changed files with 152 additions and 43 deletions.
23 changes: 20 additions & 3 deletions Sources/Misc/Concurrency/OperationDispatcher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ class OperationDispatcher {

private let mainQueue: DispatchQueue = .main
private let workerQueue: DispatchQueue = .init(label: "OperationDispatcherWorkerQueue")
private let maxJitterInSeconds: Double = 5

static let `default`: OperationDispatcher = .init()

Expand All @@ -43,13 +42,31 @@ class OperationDispatcher {

func dispatchOnWorkerThread(withRandomDelay: Bool = false, block: @escaping @Sendable () -> Void) {
if withRandomDelay {
let delay = Double.random(in: 0..<self.maxJitterInSeconds)
self.workerQueue.asyncAfter(deadline: .now() + delay, execute: block)
self.workerQueue.asyncAfter(deadline: .now() + Self.randomDelay(), execute: block)
} else {
self.workerQueue.async(execute: block)
}
}

@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.2, *)
func dispatchOnWorkerThread(withRandomDelay: Bool = false, block: @escaping @Sendable () async -> Void) {
Task.detached(priority: .background) {
if withRandomDelay {
try? await Task.sleep(nanoseconds: DispatchTimeInterval(Self.randomDelay()).nanoseconds)
}

await block()
}
}

/// Prevent DDOS if a notification leads to many users opening an app at the same time,
/// by spreading asynchronous operations over time.
private static func randomDelay() -> TimeInterval {
Double.random(in: 0..<Self.maxJitterInSeconds)
}

private static let maxJitterInSeconds: Double = 5

}

extension OperationDispatcher {
Expand Down
58 changes: 35 additions & 23 deletions Sources/Paywalls/PaywallCacheWarming.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,11 @@ import Foundation

protocol PaywallCacheWarmingType: Sendable {

func warmUpEligibilityCache(offerings: Offerings)
func warmUpPaywallImagesCache(offerings: Offerings)
@available(iOS 15.0, macOS 12.0, watchOS 8.0, tvOS 15.0, *)
func warmUpEligibilityCache(offerings: Offerings) async

@available(iOS 15.0, macOS 12.0, watchOS 8.0, tvOS 15.0, *)
func warmUpPaywallImagesCache(offerings: Offerings) async

}

Expand All @@ -27,23 +30,19 @@ protocol PaywallImageFetcherType: Sendable {

}

final class PaywallCacheWarming: PaywallCacheWarmingType {
@available(iOS 15.0, macOS 12.0, watchOS 8.0, tvOS 15.0, *)
actor PaywallCacheWarming: PaywallCacheWarmingType {

private let introEligibiltyChecker: TrialOrIntroPriceEligibilityCheckerType
private let imageFetcher: PaywallImageFetcherType

convenience init(introEligibiltyChecker: TrialOrIntroPriceEligibilityCheckerType) {
final class Fetcher: PaywallImageFetcherType {
@available(iOS 15.0, macOS 12.0, watchOS 8.0, tvOS 15.0, *)
func downloadImage(_ url: URL) async throws {
_ = try await URLSession.shared.data(from: url)
}
}

self.init(introEligibiltyChecker: introEligibiltyChecker, imageFetcher: Fetcher())
}
private var hasLoadedEligibility = false
private var hasLoadedImages = false

init(introEligibiltyChecker: TrialOrIntroPriceEligibilityCheckerType, imageFetcher: PaywallImageFetcherType) {
init(
introEligibiltyChecker: TrialOrIntroPriceEligibilityCheckerType,
imageFetcher: PaywallImageFetcherType = DefaultPaywallImageFetcher()
) {
self.introEligibiltyChecker = introEligibiltyChecker
self.imageFetcher = imageFetcher

Expand All @@ -53,34 +52,47 @@ final class PaywallCacheWarming: PaywallCacheWarmingType {
}

func warmUpEligibilityCache(offerings: Offerings) {
guard !self.hasLoadedEligibility else { return }
self.hasLoadedEligibility = true

let productIdentifiers = offerings.allProductIdentifiersInPaywalls
guard !productIdentifiers.isEmpty else { return }

Logger.debug(Strings.paywalls.warming_up_eligibility_cache(products: productIdentifiers))
self.introEligibiltyChecker.checkEligibility(productIdentifiers: productIdentifiers) { _ in }
}

func warmUpPaywallImagesCache(offerings: Offerings) {
guard #available(iOS 15.0, macOS 12.0, watchOS 8.0, tvOS 15.0, *) else { return }
func warmUpPaywallImagesCache(offerings: Offerings) async {
guard !self.hasLoadedImages else { return }
self.hasLoadedImages = true

let imageURLs = offerings.allImagesInPaywalls
guard !imageURLs.isEmpty else { return }

Logger.verbose(Strings.paywalls.warming_up_images(imageURLs: imageURLs))

Task<Void, Never> {
for url in imageURLs {
do {
try await self.imageFetcher.downloadImage(url)
} catch {
Logger.error(Strings.paywalls.error_prefetching_image(url, error))
}
for url in imageURLs {
do {
try await self.imageFetcher.downloadImage(url)
} catch {
Logger.error(Strings.paywalls.error_prefetching_image(url, error))
}
}
}

}

// MARK: -

final class DefaultPaywallImageFetcher: PaywallImageFetcherType {

@available(iOS 15.0, macOS 12.0, watchOS 8.0, tvOS 15.0, *)
func downloadImage(_ url: URL) async throws {
_ = try await URLSession.shared.data(from: url)
}

}

// MARK: - Extensions

private extension Offerings {
Expand Down
19 changes: 13 additions & 6 deletions Sources/Purchasing/Purchases/Purchases.swift
Original file line number Diff line number Diff line change
Expand Up @@ -237,7 +237,7 @@ public typealias StartPurchaseBlock = (@escaping PurchaseCompletedBlock) -> Void
private let attributionPoster: AttributionPoster
private let backend: Backend
private let deviceCache: DeviceCache
private let paywallCache: PaywallCacheWarmingType
private let paywallCache: PaywallCacheWarmingType?
private let identityManager: IdentityManager
private let userDefaults: UserDefaults
private let notificationCenter: NotificationCenter
Expand Down Expand Up @@ -428,7 +428,13 @@ public typealias StartPurchaseBlock = (@escaping PurchaseCompletedBlock) -> Void
productsManager: productsManager)
)

let paywallCache = PaywallCacheWarming(introEligibiltyChecker: trialOrIntroPriceChecker)
let paywallCache: PaywallCacheWarmingType?

if #available(iOS 15.0, macOS 12.0, watchOS 8.0, tvOS 15.0, *) {
paywallCache = PaywallCacheWarming(introEligibiltyChecker: trialOrIntroPriceChecker)
} else {
paywallCache = nil
}

self.init(appUserID: appUserID,
requestFetcher: fetcher,
Expand Down Expand Up @@ -468,7 +474,7 @@ public typealias StartPurchaseBlock = (@escaping PurchaseCompletedBlock) -> Void
systemInfo: SystemInfo,
offeringsFactory: OfferingsFactory,
deviceCache: DeviceCache,
paywallCache: PaywallCacheWarmingType,
paywallCache: PaywallCacheWarmingType?,
identityManager: IdentityManager,
subscriberAttributes: Attribution,
operationDispatcher: OperationDispatcher,
Expand Down Expand Up @@ -1631,10 +1637,11 @@ private extension Purchases {
appUserID: self.appUserID,
isAppBackgrounded: isAppBackgrounded
) { [cache = self.paywallCache] offerings in
if let offerings = offerings.value {
if #available(iOS 15.0, macOS 12.0, watchOS 8.0, tvOS 15.0, *),
let cache = cache, let offerings = offerings.value {
self.operationDispatcher.dispatchOnWorkerThread {
cache.warmUpEligibilityCache(offerings: offerings)
cache.warmUpPaywallImagesCache(offerings: offerings)
await cache.warmUpEligibilityCache(offerings: offerings)
await cache.warmUpPaywallImagesCache(offerings: offerings)
}
}
}
Expand Down
25 changes: 25 additions & 0 deletions Tests/TestingApps/SimpleApp/SimpleApp/Products.storekit
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,31 @@
"referenceName" : "Yearly",
"subscriptionGroupID" : "CEEF018E",
"type" : "RecurringSubscription"
},
{
"adHocOffers" : [

],
"codeOffers" : [

],
"displayPrice" : "0.99",
"familyShareable" : false,
"groupNumber" : 1,
"internalID" : "2BD0E5CA",
"introductoryOffer" : null,
"localizations" : [
{
"description" : "Weekly",
"displayName" : "Weekly",
"locale" : "en_US"
}
],
"productID" : "com.revenuecat.simpleapp.weekly",
"recurringSubscriptionPeriod" : "P1W",
"referenceName" : "Weekly",
"subscriptionGroupID" : "CEEF018E",
"type" : "RecurringSubscription"
}
]
}
Expand Down
68 changes: 57 additions & 11 deletions Tests/UnitTests/Paywalls/PaywallCacheWarmingTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,23 +15,27 @@ import Nimble
@testable import RevenueCat
import XCTest

@MainActor
@available(iOS 15.0, macOS 12.0, watchOS 8.0, tvOS 15.0, *)
class PaywallCacheWarmingTests: TestCase {

private var eligibilityChecker: MockTrialOrIntroPriceEligibilityChecker!
private var imageFetcher: MockPaywallImageFetcher!
private var cache: PaywallCacheWarmingType!

override func setUp() {
super.setUp()
override func setUpWithError() throws {
try super.setUpWithError()

try AvailabilityChecks.iOS15APIAvailableOrSkipTest()

self.eligibilityChecker = .init()
self.imageFetcher = .init()
self.cache = PaywallCacheWarming(introEligibiltyChecker: self.eligibilityChecker,
imageFetcher: self.imageFetcher)
}

func testOfferingsWithNoPaywallsDoesNotCheckEligibility() throws {
self.cache.warmUpEligibilityCache(
func testOfferingsWithNoPaywallsDoesNotCheckEligibility() async throws {
await self.cache.warmUpEligibilityCache(
offerings: try Self.createOfferings([
Self.createOffering(
identifier: Self.offeringIdentifier,
Expand All @@ -46,7 +50,7 @@ class PaywallCacheWarmingTests: TestCase {
expect(self.eligibilityChecker.invokedCheckTrialOrIntroPriceEligibilityFromOptimalStore) == false
}

func testWarmsUpEligibilityCache() throws {
func testWarmsUpEligibilityCache() async throws {
let paywall = try Self.loadPaywall("PaywallData-Sample1")
let offerings = try Self.createOfferings([
Self.createOffering(
Expand All @@ -66,7 +70,7 @@ class PaywallCacheWarmingTests: TestCase {
)
])

self.cache.warmUpEligibilityCache(offerings: offerings)
await self.cache.warmUpEligibilityCache(offerings: offerings)

expect(self.eligibilityChecker.invokedCheckTrialOrIntroPriceEligibilityFromOptimalStore) == true
expect(self.eligibilityChecker.invokedCheckTrialOrIntroPriceEligibilityFromOptimalStoreCount) == 1
Expand All @@ -80,14 +84,36 @@ class PaywallCacheWarmingTests: TestCase {

self.logger.verifyMessageWasLogged(
Strings.paywalls.warming_up_eligibility_cache(products: ["product_1", "product_3"]),
level: .debug
)
}

func testOnlyWarmsUpEligibilityCacheOnce() async throws {
let paywall = try Self.loadPaywall("PaywallData-Sample1")
let offerings = try Self.createOfferings([
Self.createOffering(
identifier: Self.offeringIdentifier,
paywall: paywall,
products: [
(.monthly, "product_1")
]
)
])

await self.cache.warmUpEligibilityCache(offerings: offerings)
await self.cache.warmUpEligibilityCache(offerings: offerings)

expect(self.eligibilityChecker.invokedCheckTrialOrIntroPriceEligibilityFromOptimalStore) == true
expect(self.eligibilityChecker.invokedCheckTrialOrIntroPriceEligibilityFromOptimalStoreCount) == 1

self.logger.verifyMessageWasLogged(
Strings.paywalls.warming_up_eligibility_cache(products: ["product_1"]),
level: .debug,
expectedCount: 1
)
}

func testWarmsUpImages() throws {
try AvailabilityChecks.iOS15APIAvailableOrSkipTest()

func testWarmsUpImages() async throws {
let paywall = try Self.loadPaywall("PaywallData-Sample1")
let offerings = try Self.createOfferings([
Self.createOffering(
Expand All @@ -103,13 +129,31 @@ class PaywallCacheWarmingTests: TestCase {
"https://rc-paywalls.s3.amazonaws.com/icon.jpg"
]

self.cache.warmUpPaywallImagesCache(offerings: offerings)
await self.cache.warmUpPaywallImagesCache(offerings: offerings)

expect(self.imageFetcher.images) == expectedURLs
expect(self.imageFetcher.imageDownloadRequestCount.value) == expectedURLs.count
}

func testOnlyWarmsUpImagesOnce() async throws {
let paywall = try Self.loadPaywall("PaywallData-Sample1")
let offerings = try Self.createOfferings([
Self.createOffering(
identifier: Self.offeringIdentifier,
paywall: paywall,
products: []
)
])

await self.cache.warmUpPaywallImagesCache(offerings: offerings)
await self.cache.warmUpPaywallImagesCache(offerings: offerings)

expect(self.imageFetcher.images).toEventually(equal(expectedURLs))
expect(self.imageFetcher.imageDownloadRequestCount.value) == 3
}

}

@available(iOS 15.0, macOS 12.0, watchOS 8.0, tvOS 15.0, *)
private extension PaywallCacheWarmingTests {

static func createOffering(
Expand Down Expand Up @@ -162,6 +206,7 @@ private extension PaywallCacheWarmingTests {
private final class MockPaywallImageFetcher: PaywallImageFetcherType {

let downloadedImages: Atomic<Set<URL>> = .init([])
let imageDownloadRequestCount: Atomic<Int> = .init(0)

var images: Set<String> {
return Set(self.downloadedImages.value.map(\.absoluteString))
Expand All @@ -170,6 +215,7 @@ private final class MockPaywallImageFetcher: PaywallImageFetcherType {
@available(iOS 15.0, macOS 12.0, watchOS 8.0, tvOS 15.0, *)
func downloadImage(_ url: URL) async throws {
self.downloadedImages.modify { $0.insert(url) }
self.imageDownloadRequestCount.modify { $0 += 1 }
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,8 @@ class PurchasesGetOfferingsTests: BasePurchasesTests {
}

func testWarmsUpPaywallsCache() throws {
try AvailabilityChecks.iOS15APIAvailableOrSkipTest()

let bundle = Bundle(for: Self.self)
let offeringsURL = try XCTUnwrap(bundle.url(forResource: "Offerings",
withExtension: "json",
Expand Down

0 comments on commit 12672a7

Please sign in to comment.