Skip to content

Created CachingProductsManager to provide consistent caching logic when fetching products#1907

Merged
NachoSoto merged 3 commits into
mainfrom
sk2-product-request-caching
Sep 28, 2022
Merged

Created CachingProductsManager to provide consistent caching logic when fetching products#1907
NachoSoto merged 3 commits into
mainfrom
sk2-product-request-caching

Conversation

@NachoSoto

@NachoSoto NachoSoto commented Sep 15, 2022

Copy link
Copy Markdown
Contributor

Fixes SDKONCALL-129.

Goal

The main feature that this change introduces is providing a request cache to ProductsFetcherSK2 (like ProductsFetcherSK1 has).
It already implements a product cache, but concurrent requests for the same products result in fetching those multiple times. It does not cache products until they're done being fetched.

This was observed in the logs when doing something simple like purchasing a product. This is running testCanPurchaseProduct integration test:

DEBUG: ℹ️ No existing products cached, starting store products request for: ["com.revenuecat.monthly_4.99.1_week_intro", "com.revenuecat.annual_39.99.2_week_intro", "com.revenuecat.weekly_1.99.3_day_intro"]
DEBUG: ℹ️ No existing products cached, starting store products request for: ["com.revenuecat.monthly_4.99.1_week_intro", "com.revenuecat.annual_39.99.2_week_intro", "com.revenuecat.weekly_1.99.3_day_intro"]
DEBUG: 😻 Store products request request received response
DEBUG: ℹ️ Store products request finished
DEBUG: 😻 Store products request request received response
DEBUG: ℹ️ Store products request finished

With this PR, we avoid the double request due to that race condition:

DEBUG: ℹ️ No existing products cached, starting store products request for: ["com.revenuecat.annual_39.99.2_week_intro", "com.revenuecat.weekly_1.99.3_day_intro", "com.revenuecat.monthly_4.99.1_week_intro"]
DEBUG: 😻 Store products request request received response
DEBUG: ℹ️ Store products request finished

How

Instead of implementing that logic only on ProductsFetcherSK2 or in ProductsManager, this aims to keep the implementation simple in ProductsManager and each of the fetchers, and instead uses the decorator pattern to provide that new functionality in a way that's simple to test and maintain, independent of the logic for actually fetching products.

To accomplish that, this adds a new protocol: ProductsManagerType, with 2 main methods (more about why the need for an SK2 special method below):

/// Fetches the ``StoreProduct``s with the given identifiers
/// The returned products will be SK1 or SK2 backed depending on the implementation and configuration.
func products(withIdentifiers identifiers: Set<String>, completion: @escaping Completion)

/// Fetches the `SK2StoreProduct`s with the given identifiers.
@available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *)
func sk2Products(withIdentifiers identifiers: Set<String>, completion: @escaping SK2Completion)

Those are only completion-block APIs. To avoid repetition, an extension on the protocol itself provides an async API, wrapping on top of these 2 methods, regardless of which ProductsManagerType they're being used on.

The hierarchy is now as follows:

  • CachingProductsManager
    • ProductsManager
      • ProductsFetcherSK1
      • ProductsFetcherSK2

#1908 gets rid of the now duplicate caching logic in ProductsManager and the fetchers.

Other notes

What makes the implementation a bit more convoluted is the need for a special SK2 method.
This is necessary specifically for TrialOrIntroPriceEligibilityChecker which requires fetching only SK2 products regardless of StoreKit2Setting. In order to guarantee this invariant at compile time through types, a separate cache is maintained with SK2StoreProducts. However, the main caching logic is not duplicated and instead shared for both.

The main tests for CachingProductsManager are simple unit tests that don't make use of StoreKitConfigTestCase.
Because creating SK2 products does require that, I added a separate test class for those.

Future changes

The caching logic is very simple, just like what we had for the fetchers. One missing piece is that if products A and B are requested, and only A is in the cache, this is treated as a complete cache miss.

The fetchers had the same problem, but now that this caching logic is isolated and much easier to test, we can evolve and solve that feature in a more maintainable way.

@NachoSoto

Copy link
Copy Markdown
Contributor Author

@RevenueCat/core-sdk bump

@tonidero tonidero left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just a few questions but looking great!

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this also clear the request cache?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That would break requests that are currently in progress.

Comment thread Sources/Purchasing/CachingProductsManager.swift Outdated

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To make this more explicit, should we rename this to StoreKit1CachingProductsManagerTests?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test suite is testing CachingProductsManager independently of what the underlying ProductsManager is, so it would be confusing to call it SK1 since it works just the same for SK2.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm I'm wondering, why do we need to wrap the. products with StoreProduct?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This type is StoreProductType which isn't Equatable.

…when fetching products

The main feature that this change introduces is providing a _request_ cache to `ProductsFetcherSK2` (like `ProductsFetcherSK` has).
It already implements a _product_ cache, but concurrent requests for the same products result in fetching those multiple times. It does not cache products until they're done being fetched.

Instead of implementing that logic _only_ on `ProductsFetcherSK2` or in `ProductsManager`, this aims to keep the implementation simple in `ProductsManager` and each of the fetchers, and instead uses the decorator pattern to provide that new functionality in a way that's simple to test and maintain, independent of the logic for actually fetching products.

To accomplish that, this adds a new `protocol`: `ProductsManagerType`, with 2 main methods (more about why the need for an SK2 special method below):
```swift
/// Fetches the ``StoreProduct``s with the given identifiers
/// The returned products will be SK1 or SK2 backed depending on the implementation and configuration.
func products(withIdentifiers identifiers: Set<String>, completion: @escaping Completion)

/// Fetches the `SK2StoreProduct`s with the given identifiers.
@available(iOS 15.0, tvOS 15.0, watchOS 8.0, macOS 12.0, *)
func sk2Products(withIdentifiers identifiers: Set<String>, completion: @escaping SK2Completion)
```

Those are only completion-block APIs. To avoid repetition, an `extension` on the `protocol` itself provides an `async` API, wrapping on top of these 2 methods, regardless of which `ProductsManagerType` they're being used on.

The hierarchy is now as follows:
- `CachingProductsManager`
    - `ProductsManager`
        - `ProductsFetcherSK1`
        - `ProductsFetcherSK2`

An upcoming PR will get rid of the now duplicate caching logic in `ProductsManager` and the fetchers.

What makes the implementation a bit more convoluted is the need for a special SK2 method.
This is necessary specifically for `TrialOrIntroPriceEligibilityChecker` which requires fetching only SK2 products regardless of `StoreKit2Setting`. In order to guarantee this invariant at compile time through types, a separate cache is maintained with `SK2StoreProduct`s. However, the main caching logic is *not* duplicated and instead shared for both.

The main tests for `CachingProductsManager` are simple unit tests that don't make use of `StoreKitConfigTestCase`.
Because creating SK2 products does require that, I added a separate test class for those.
@NachoSoto NachoSoto force-pushed the sk2-product-request-caching branch from fde5fc4 to 6eef764 Compare September 28, 2022 15:58
@NachoSoto NachoSoto merged commit 1879f4a into main Sep 28, 2022
@NachoSoto NachoSoto deleted the sk2-product-request-caching branch September 28, 2022 16:23
NachoSoto added a commit that referenced this pull request Sep 28, 2022
Follow up to #1907.

This logic is now implemented and shared in `CachingProductsManager`.

I've decided to keep the logic in `ProductsFetcherSK1` because it's
intertwined with the retry mechanism, and it wasn't trivial to remove
caching while leaving that logic.
NachoSoto pushed a commit that referenced this pull request Sep 28, 2022
**This is an automatic release.**

### Other Changes
* `ProductsFetcherSK2`: removed now redundant caching logic (#1908) via
NachoSoto (@NachoSoto)
* Created `CachingProductsManager` to provide consistent caching logic
when fetching products (#1907) via NachoSoto (@NachoSoto)
* Refactored `ReceiptFetcher.receiptData` (#1941) via NachoSoto
(@NachoSoto)
* Abstracted conversion from `async` to completion-block APIs (#1943)
via NachoSoto (@NachoSoto)
* Moved `InAppPurchase` into `AppleReceipt` (#1942) via NachoSoto
(@NachoSoto)
* `Purchases+async`: combined `@available` statements into a single one
(#1944) via NachoSoto (@NachoSoto)
* `Integration Tests`: don't initialize `Purchases` until the
`SKTestSession` has been re-created (#1946) via NachoSoto (@NachoSoto)
* `PostReceiptDataOperation`: print receipt data if `debug` logs are
enabled (#1940) via NachoSoto (@NachoSoto)

Co-authored-by: RCGitBot <dev+RCGitBot@revenuecat.com>
NachoSoto added a commit that referenced this pull request Nov 8, 2022
#1907 introduced this `CachingProductsManager` abstraction, but simply copied the existing caching mechanism, which was very limited.
This expands on it by not ignoring cached products when not _all_ requested products are found. Instead, it uses the partial cache hit, and requests the rest of products.

This performance optimization is illustrated in the test change, as the second request only fetches the missing product now.
NachoSoto added a commit that referenced this pull request Nov 8, 2022
#1907 introduced this `CachingProductsManager` abstraction, but simply
copied the existing caching mechanism, which was very limited. This
expands on it by not ignoring cached products when not _all_ requested
products are found. Instead, it uses the partial cache hit, and requests
the rest of products.

This performance optimization is illustrated in the test change, as the
second request only fetches the missing product now.
NachoSoto added a commit that referenced this pull request Jul 26, 2023
I noticed this was being logged twice when requesting SK1 products, which makes it seem like we're fetching them twice:
```
[store_kit] DEBUG: ℹ️ No existing products cached, starting store products request for: ["com.revenuecat.monthly_4.99.1_week_intro"]
```

`CachingProductsManager` was introduced in #1907. Explained there is why `ProductsFetcherSK1` keeps some caching behavior.
But the main caching layer is now `CachingProductsManager`, so it makes sense to keep the log only there.

This adds 4 basic integration tests. We had independent coverage of `ProductsManager` and `CachingProductsManager` with a mock `ProductsManager`, but no unit tests covering both together. This serves that purpose too.
NachoSoto added a commit that referenced this pull request Jul 26, 2023
I noticed this was being logged twice when requesting SK1 products,
which makes it seem like we're fetching them twice:
```
[store_kit] DEBUG: ℹ️ No existing products cached, starting store products request for: ["com.revenuecat.monthly_4.99.1_week_intro"]
```

`CachingProductsManager` was introduced in #1907. Explained there is why
`ProductsFetcherSK1` keeps some caching behavior.
But the main caching layer is now `CachingProductsManager`, so it makes
sense to keep the log only there.

This adds 4 basic integration tests. We had independent coverage of
`ProductsManager` and `CachingProductsManager` with a mock
`ProductsManager`, but no unit tests covering both together. This serves
that purpose too.
@vegaro vegaro added pr:other and removed pr:perf labels Sep 17, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants