Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 26 additions & 27 deletions Sources/Purchasing/OfferingsManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -91,31 +91,27 @@ class OfferingsManager {

let cacheStatus = self.deviceCache.offeringsCacheStatus(isAppBackgrounded: isAppBackgrounded)
Logger.debug(Strings.offering.vending_offerings_cache_from_memory)
self.trackGetOfferingsResultIfNeeded(trackDiagnostics: trackDiagnostics,
startTime: startTime,
cacheStatus: cacheStatus,
error: nil,
requestedProductIds: nil,
notFoundProductIds: nil)

if cacheStatus == .stale {
// Serve the cached offerings immediately and refresh in the background, preserving the
// existing "return fast, update later" behavior. The background update goes through the
// same workflows-aware delivery path, so it refreshes the workflows list too. Gating
// here as well would both block delivery and double-fetch the list.
self.dispatchCompletionOnMainThreadIfPossible(completion, value: .success(memoryCachedOfferings))
// Refresh in the background; the readiness gate below must not delay it.
Comment thread
facumenzella marked this conversation as resolved.
self.updateOfferingsCache(appUserID: appUserID,
isAppBackgrounded: isAppBackgrounded,
fetchPolicy: fetchPolicy,
completion: nil)
} else {
// Fresh offerings (no background refresh coming): ensure remote config has synced at
// least once before delivering, so a caller resolving a workflow right after
// `getOfferings` succeeds isn't racing the first config sync. This is a no-op once the
// `workflows` topic is already populated, which it normally is on this path.
self.deliverWhenConfigReady {
self.dispatchCompletionOnMainThreadIfPossible(completion, value: .success(memoryCachedOfferings))
}
}
// Cached offerings, stale ones included, wait for config readiness before
// delivery (a no-op once config has synced). A stale snapshot may be returned
// even if the background refresh finished first; that matches the stale-cache
// model, and the refresh still updates the cache for the next call.
self.deliverWhenConfigReady {
// Tracked inside the gate so the recorded latency includes the readiness
// wait this delivery now pays, not just the cache lookup.
self.trackGetOfferingsResultIfNeeded(trackDiagnostics: trackDiagnostics,
startTime: startTime,
cacheStatus: cacheStatus,
error: nil,
requestedProductIds: nil,
notFoundProductIds: nil)
self.dispatchCompletionOnMainThreadIfPossible(completion, value: .success(memoryCachedOfferings))
}
Comment thread
cursor[bot] marked this conversation as resolved.
}
}
Expand Down Expand Up @@ -328,6 +324,9 @@ private extension OfferingsManager {
preferredLocales: preferredLocales,
appUserID: appUserID)

// A background refresh (nil completion) only updates the cache; skip the
// readiness gate so it doesn't await (and decode) config for a no-op delivery.
guard let completion else { return }
// Delivers offerings only once remote config has synced at least once, so the
// `workflows` topic is available for resolving a workflow right after this call.
self.deliverWhenConfigReady {
Expand Down Expand Up @@ -385,19 +384,19 @@ private extension OfferingsManager {
}
}

/// Runs `deliver` once remote config has synced at least once *and* every workflow flagged for
/// prefetch has finished downloading (or failed), when remote config is wired. Matches Android's
/// guarantee that offerings delivery waits for every prefetched workflow body, not just the
/// `workflows` topic's metadata. The manager's `awaitTopicAndPrefetchBlobsReady()` read no-ops when already synced
/// and always calls back, so this never hangs. When no manager is wired (workflows disabled),
/// `deliver` runs immediately, leaving offerings unchanged.
/// Invokes `deliver` once the paywall config data `getOfferings` depends on is ready:
/// the `workflows` topic (with its prefetch-flagged blobs) and the `ui_config` body,
/// resolved concurrently. Both are best-effort (nil on failure, never throwing), so
/// delivery can never be stranded; when no manager is wired, `deliver` runs immediately.
private func deliverWhenConfigReady(deliver: @escaping () -> Void) {
guard let remoteConfigManager = self.remoteConfigManager else {
deliver()
return
}
Task {
_ = await remoteConfigManager.awaitTopicAndPrefetchBlobsReady(.workflows)
async let workflowsReady = remoteConfigManager.awaitTopicAndPrefetchBlobsReady(.workflows)
async let uiConfigReady = UiConfigProvider(manager: remoteConfigManager).getUiConfig()
_ = await (workflowsReady, uiConfigReady)
deliver()
}
}
Expand Down
146 changes: 140 additions & 6 deletions Tests/UnitTests/Purchasing/OfferingsManagerTests.swift
Comment thread
facumenzella marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -969,7 +969,11 @@ private extension OfferingsManagerTests {
static let unexpectedBackendResponseError: BackendError = .unexpectedBackendResponse(
.customerInfoNil
)
static let sampleOfferings: Offerings = .init(
static let sampleOfferings: Offerings = MockData.makeSampleOfferings()

/// A fresh `Offerings` instance per call, for tests that need two distinguishable snapshots.
static func makeSampleOfferings() -> Offerings {
return .init(
offerings: MockData.anyBackendOfferingsContents.response.offerings
.map { offering in
Offering(
Expand All @@ -996,7 +1000,8 @@ private extension OfferingsManagerTests {
targeting: nil,
contents: MockData.anyBackendOfferingsContents,
loadedFromDiskCache: false
)
)
}
}

}
Expand Down Expand Up @@ -1034,6 +1039,92 @@ extension OfferingsManagerTests {
expect(delivered.value).toEventually(beTrue())
}

func testGetOfferingsDoesNotDeliverUntilUiConfigResolved() {
let mockRemoteConfigManager = MockRemoteConfigManager()
// The workflows topic resolves immediately; ui_config's blob reads are held.
mockRemoteConfigManager.shouldStoreBlobDataCompletion = true
let manager = self.makeOfferingsManager(remoteConfigManager: mockRemoteConfigManager)
self.mockOfferings.stubbedGetOfferingsCompletionResult = .success(MockData.anyBackendOfferingsContents)

let delivered: Atomic<Bool> = false
manager.offerings(appUserID: MockData.anyAppUserID) { _ in delivered.value = true }

// The ui_config read was started but is held, so offerings must wait.
expect(mockRemoteConfigManager.invokedBlobDataParameters).toEventuallyNot(beEmpty())
expect(delivered.value) == false

mockRemoteConfigManager.completeStoredBlobReads()
expect(delivered.value).toEventually(beTrue())
}

func testGetOfferingsDeliversWhenUiConfigResolutionFails() {
// No ui_config blobs stubbed: resolution fails (nil). Readiness is best-effort, so
// delivery must proceed anyway with the real offerings.
let manager = self.makeOfferingsManager(remoteConfigManager: MockRemoteConfigManager())
self.mockOfferings.stubbedGetOfferingsCompletionResult = .success(MockData.anyBackendOfferingsContents)

let result = waitUntilValue { completed in
manager.offerings(appUserID: MockData.anyAppUserID) { completed($0) }
}

expect(result).to(beSuccess())
expect(result?.value?["base"]).toNot(beNil())
expect(result?.value?["base"]?.monthly?.storeProduct).toNot(beNil())
}

func testGetOfferingsDeliversEvenIfTheGateTaskIsCancelled() {
// The gate's two readiness awaits are non-throwing and always awaited before the
// callback fires, so no cancellation can strand it. Model that here by resolving both
// to their empty/failed states (no workflows topic, no ui_config) and asserting the
// completion still runs.
let manager = self.makeOfferingsManager(remoteConfigManager: MockRemoteConfigManager())
self.mockOfferings.stubbedGetOfferingsCompletionResult = .success(MockData.anyBackendOfferingsContents)

let result = waitUntilValue { completed in
manager.offerings(appUserID: MockData.anyAppUserID) { completed($0) }
}

expect(result).to(beSuccess())
}

func testBackgroundCacheRefreshCachesWithoutAwaitingTheConfigGate() {
// A background refresh passes a nil completion: it has nothing to deliver, so it must
// cache the fetched offerings without entering the readiness gate. The held topic
// would block the gate forever; asserting the cache still lands (and the gate's topic
// read never happens) locks the skip.
let mockRemoteConfigManager = MockRemoteConfigManager()
mockRemoteConfigManager.shouldStoreTopicCompletion = true
let manager = self.makeOfferingsManager(remoteConfigManager: mockRemoteConfigManager)
self.mockOfferings.stubbedGetOfferingsCompletionResult = .success(MockData.anyBackendOfferingsContents)

manager.updateOfferingsCache(appUserID: MockData.anyAppUserID,
isAppBackgrounded: false,
completion: nil)

expect(self.mockDeviceCache.cacheOfferingsCount).toEventually(equal(1))
expect(mockRemoteConfigManager.invokedTopicCount) == 0
}

func testGetOfferingsAwaitsWorkflowsAndUiConfigConcurrently() {
let mockRemoteConfigManager = MockRemoteConfigManager()
// Hold BOTH readiness steps: each must have started before either completes.
mockRemoteConfigManager.shouldStoreTopicCompletion = true
mockRemoteConfigManager.shouldStoreBlobDataCompletion = true
let manager = self.makeOfferingsManager(remoteConfigManager: mockRemoteConfigManager)
self.mockOfferings.stubbedGetOfferingsCompletionResult = .success(MockData.anyBackendOfferingsContents)

let delivered: Atomic<Bool> = false
manager.offerings(appUserID: MockData.anyAppUserID) { _ in delivered.value = true }

expect(mockRemoteConfigManager.invokedTopicCount).toEventually(beGreaterThan(0))
expect(mockRemoteConfigManager.invokedBlobDataParameters).toEventuallyNot(beEmpty())
expect(delivered.value) == false

mockRemoteConfigManager.completeStoredTopic()
mockRemoteConfigManager.completeStoredBlobReads()
expect(delivered.value).toEventually(beTrue())
}

func testGetOfferingsDeliversEvenWhenRemoteConfigHasNoWorkflowsTopic() {
// A manager with no committed `workflows` topic (e.g. never synced) must still call back, so
// offerings delivery can never hang.
Expand Down Expand Up @@ -1067,9 +1158,8 @@ extension OfferingsManagerTests {
expect(delivered.value).toEventually(beTrue())
}

func testGetOfferingsFromStaleMemoryCacheDeliversImmediately() {
func testGetOfferingsFromStaleMemoryCacheGatesDeliveryAndStillRefreshes() {
let mockRemoteConfigManager = MockRemoteConfigManager()
// Hold the topic completion so a foreground wait, if present, would block delivery.
mockRemoteConfigManager.shouldStoreTopicCompletion = true
let manager = self.makeOfferingsManager(remoteConfigManager: mockRemoteConfigManager)
// Cached but stale, so the background refresh runs.
Expand All @@ -1080,12 +1170,56 @@ extension OfferingsManagerTests {
let delivered: Atomic<Bool> = false
manager.offerings(appUserID: MockData.anyAppUserID) { _ in delivered.value = true }

// Cached offerings are delivered immediately, not blocked on the held topic completion.
// The background refresh starts regardless of the gate...
expect(self.mockOfferings.invokedGetOfferingsForAppUserID).toEventually(beTrue())
// ...but delivery, even from a stale cache, waits for config readiness.
expect(mockRemoteConfigManager.invokedTopicCount).toEventually(beGreaterThan(0))
expect(delivered.value) == false

mockRemoteConfigManager.completeStoredTopic()
expect(delivered.value).toEventually(beTrue())
}

// Drain the background refresh's config-ready wait so its `Task` doesn't leak past this test.
func testGatedFreshCacheDeliveryNeverReadsTheSharedCacheSlot() {
let mockRemoteConfigManager = MockRemoteConfigManager()
mockRemoteConfigManager.shouldStoreTopicCompletion = true
let manager = self.makeOfferingsManager(remoteConfigManager: mockRemoteConfigManager)
let captured = MockData.makeSampleOfferings()
self.mockDeviceCache.stubbedOfferings = captured
self.mockDeviceCache.stubbedOfferingCacheStatus = .valid

let delivered: Atomic<Offerings?> = .init(nil)
manager.offerings(appUserID: MockData.anyAppUserID) { result in delivered.value = result.value }

// While delivery waits on the gate, an unrelated write (identity change, another
// request) repopulates the shared cache slot. It must not leak into this request.
expect(mockRemoteConfigManager.invokedTopicCount).toEventually(beGreaterThan(0))
self.mockDeviceCache.stubbedOfferings = MockData.makeSampleOfferings()

mockRemoteConfigManager.completeStoredTopic()
expect(delivered.value).toEventually(beIdenticalTo(captured))
}

func testGatedStaleCacheDeliversTheCapturedSnapshotNotALaterCacheWrite() {
let mockRemoteConfigManager = MockRemoteConfigManager()
mockRemoteConfigManager.shouldStoreTopicCompletion = true
let manager = self.makeOfferingsManager(remoteConfigManager: mockRemoteConfigManager)
let captured = MockData.makeSampleOfferings()
self.mockDeviceCache.stubbedOfferings = captured
self.mockDeviceCache.stubbedOfferingCacheStatus = .stale
self.mockOfferings.stubbedGetOfferingsCompletionResult = .success(MockData.anyBackendOfferingsContents)

let delivered: Atomic<Offerings?> = .init(nil)
manager.offerings(appUserID: MockData.anyAppUserID) { result in delivered.value = result.value }

// While delivery waits on the gate, the background refresh writes a new slot value.
expect(mockRemoteConfigManager.invokedTopicCount).toEventually(beGreaterThan(0))
self.mockDeviceCache.stubbedOfferings = MockData.makeSampleOfferings()

mockRemoteConfigManager.completeStoredTopic()
// Stale delivery returns the snapshot captured for this request, never a later
// cache write (the refresh's, or another request's).
expect(delivered.value).toEventually(beIdenticalTo(captured))
}

func testGetOfferingsNetworkFetchAwaitsConfigReadyBeforeDelivering() {
Expand Down
55 changes: 39 additions & 16 deletions Tests/UnitTests/Purchasing/Purchases/BasePurchasesTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -714,38 +714,61 @@ final class MockRemoteConfigManager: RemoteConfigManagerType {

private let _invokedTopicCount: Atomic<Int> = .init(0)
var invokedTopicCount: Int { return self._invokedTopicCount.value }
/// When `true`, `topic(_:)` suspends until `completeStoredTopic()` is called, so tests can control
/// the timing of a caller awaiting it (e.g. `OfferingsManager`'s config-ready gate).
/// When `true`, `topic(_:)` suspends until `completeStoredTopic()` resumes every stored
/// waiter (there can be several: e.g. a gated delivery plus a background refresh).
var shouldStoreTopicCompletion = false
private let _storedTopicContinuation: Atomic<CheckedContinuation<RemoteConfiguration.ConfigTopic?, Never>?> =
.init(nil)
private let _storedTopicContinuations: Atomic<[CheckedContinuation<RemoteConfiguration.ConfigTopic?, Never>]> =
.init([])

func topic(_ topic: RemoteConfigTopic) async -> RemoteConfiguration.ConfigTopic? {
guard self.shouldStoreTopicCompletion else {
self._invokedTopicCount.modify { $0 += 1 }
return self.stubbedTopics[topic]
}
return await withCheckedContinuation { continuation in
// Resume any previously stored continuation so it can't leak if `topic(_:)` is called again
// before the prior call's completion is triggered.
self._storedTopicContinuation.getAndSet(continuation)?.resume(returning: nil)
// Increment only after the continuation is stored, so a test polling `invokedTopicCount`
// can never observe readiness before `completeStoredTopic()` has something to resume.
// Store before incrementing, so a test polling `invokedTopicCount` can never
// observe readiness before `completeStoredTopic()` has something to resume.
self._storedTopicContinuations.modify { $0.append(continuation) }
self._invokedTopicCount.modify { $0 += 1 }
}
}

/// Resumes the continuation captured by a `topic(_:)` call made while `shouldStoreTopicCompletion`
/// was `true`. Requires `shouldStoreTopicCompletion == true`.
/// Resumes every waiter held while `shouldStoreTopicCompletion` was `true`, and stops
/// holding subsequent calls.
func completeStoredTopic(with result: RemoteConfiguration.ConfigTopic? = nil) {
let continuation = self._storedTopicContinuation.value
self._storedTopicContinuation.value = nil
continuation?.resume(returning: result)
self.shouldStoreTopicCompletion = false
for continuation in self._storedTopicContinuations.getAndSet([]) {
continuation.resume(returning: result)
}
}

/// When `true`, `blobData(for:itemKey:)` suspends until `completeStoredBlobReads()` is
/// called, so tests can control the timing of a caller resolving blob-backed data.
var shouldStoreBlobDataCompletion = false
private typealias StoredBlobRead = (
topic: RemoteConfigTopic, itemKey: String, continuation: CheckedContinuation<Data?, Never>
)
private let _storedBlobReads: Atomic<[StoredBlobRead]> = .init([])

func blobData(for topic: RemoteConfigTopic, itemKey: String) async -> Data? {
self._invokedBlobDataParameters.modify { $0.append((topic, itemKey)) }
return self.stubbedBlobData[topic]?[itemKey]
guard self.shouldStoreBlobDataCompletion else {
self._invokedBlobDataParameters.modify { $0.append((topic, itemKey)) }
return self.stubbedBlobData[topic]?[itemKey]
}
return await withCheckedContinuation { continuation in
// Store before recording, so a poller can't observe readiness with nothing to resume.
self._storedBlobReads.modify { $0.append((topic, itemKey, continuation)) }
self._invokedBlobDataParameters.modify { $0.append((topic, itemKey)) }
}
}

/// Resumes every held blob read with its key's stubbed data and stops holding subsequent
/// reads, so sequential read chains (like `mergeItemsBlobData`'s loop) run to completion.
func completeStoredBlobReads() {
self.shouldStoreBlobDataCompletion = false
for read in self._storedBlobReads.getAndSet([]) {
read.continuation.resume(returning: self.stubbedBlobData[read.topic]?[read.itemKey])
}
}

func blobData<T: Decodable>(
Expand Down