Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Optimize SDK initialization when requests executed before any activity starts #1204

Merged
merged 6 commits into from
Aug 22, 2023

Conversation

tonidero
Copy link
Contributor

@tonidero tonidero commented Aug 18, 2023

Description

We discovered that the SDK might take a while to respond to offerings/customer info operations if they are started before any Activity has had the chance to start. This is because the SDK considers the app to be in the background, and we have some optimizations in place where we add some jittering to backend requests performed in the background

The approach in this PR is to make it so:

  • If a request without a jitter delay is started after a request with jitter (but before it's completed), we move the callbacks to use the response from the request without jittering.
  • If a request with jittering is started after a request without jitter (but before it's completed), we will not add the request with jitter but just reuse the existing request without jittering.

Note that with this approach, we might be duplicating requests in these situations... But I think it should be ok, lmk if you think otherwise

@tonidero tonidero added the perf A code change that improves performance label Aug 18, 2023
Copy link
Contributor Author

@tonidero tonidero left a comment

Choose a reason for hiding this comment

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

I haven't started on tests, since I wanted to gather some feedback first

@@ -94,6 +95,7 @@ internal class OfferingsManager(
},
onSuccess = { offerings ->
offeringsCache.cacheOfferings(offerings, offeringsJSON)
offeringsCache.setOfferingsCacheTimestampToNow()
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is another optimization I had to do... If we left it where it was before, we wouldn't use the new mechanisms to optimize the requests trying to use the request that is supposed to finish earlier.

Copy link
Member

Choose a reason for hiding this comment

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

I wonder if these two lines should just be a part of cacheOfferings in offeringsCache and guarantee atomicity there

Copy link
Member

Choose a reason for hiding this comment

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

Feels like it'd be conceptually cleaner and slightly easier to make it synchronized?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Certainly, these 2 could be joined (this is the only place they are both called) I will do so in a different PR.

@@ -25,6 +26,7 @@ internal class SubscriberAttributesPoster(
Endpoint.PostAttributes(appUserID),
mapOf("attributes" to attributes),
postFieldsToSign = null,
delay = Delay.DEFAULT,
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is another change that I thought was fine but lmk if you have any concerns... Basically, this will add jittering to the subscriber attributes requests, which didn't exist before. I don't think it's critical so it made sense to me to delay it in favor of other requests.

@codecov
Copy link

codecov bot commented Aug 18, 2023

Codecov Report

Merging #1204 (088de84) into main (7feda65) will decrease coverage by 0.02%.
The diff coverage is 82.35%.

@@            Coverage Diff             @@
##             main    #1204      +/-   ##
==========================================
- Coverage   85.88%   85.87%   -0.02%     
==========================================
  Files         183      183              
  Lines        6229     6252      +23     
  Branches      905      910       +5     
==========================================
+ Hits         5350     5369      +19     
- Misses        534      537       +3     
- Partials      345      346       +1     
Files Changed Coverage Δ
...com/revenuecat/purchases/strings/NetworkStrings.kt 0.00% <ø> (ø)
.../kotlin/com/revenuecat/purchases/common/Backend.kt 85.13% <79.31%> (-0.01%) ⬇️
...n/com/revenuecat/purchases/amazon/AmazonBackend.kt 86.66% <100.00%> (+0.45%) ⬆️
...n/com/revenuecat/purchases/common/BackendHelper.kt 96.15% <100.00%> (-3.85%) ⬇️
...cat/purchases/common/offerings/OfferingsManager.kt 83.87% <100.00%> (ø)
...subscriberattributes/SubscriberAttributesPoster.kt 100.00% <100.00%> (ø)

delay: Delay = Delay.NONE,
) {
val foregroundCacheKey = Pair(cacheKey.first, false)
val cacheKeyToUse = if (cacheKey.second && callbacks.containsKey(foregroundCacheKey)) {
Copy link
Contributor

Choose a reason for hiding this comment

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

can you create variables for cacheKey.first and cacheKey.second? I think it's a bit hard to follow what's what

Copy link
Contributor

Choose a reason for hiding this comment

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

Also maybe extract callbacks.containsKey(foregroundCacheKey) into a boolean with a descriptive name, something like foregroundCallAlreadyInPlace

Copy link
Contributor Author

Choose a reason for hiding this comment

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

can you create variables for cacheKey.first and cacheKey.second? I think it's a bit hard to follow what's what

Hmm yeah, I might create a data class to have these 2 so it's clearer 👍

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I created a data class to make things clearer here: 6aa761f, also extracted those conditions to variables to make the code more legible.

cacheKey
}
addCallback(call, dispatcher, cacheKeyToUse, functions, delay)
moveBackgroundedCallbacksToForegroundedCallbacksIfNeeded(this, cacheKey.first, cacheKey.second)
Copy link
Contributor

@vegaro vegaro Aug 18, 2023

Choose a reason for hiding this comment

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

It's only necessary to do this if this new callback being added is for an unjittered call right?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes, that's correct. We do that check inside the method, but we might be able to move it here.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Removed that function and moved it to the main one, so it's clearer in 6aa761f

@@ -61,11 +61,12 @@ internal class OfferingsManager(
onError: ((PurchasesError) -> Unit)? = null,
onSuccess: ((Offerings) -> Unit)? = null,
) {
offeringsCache.setOfferingsCacheTimestampToNow()
Copy link
Contributor

Choose a reason for hiding this comment

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

I don't recall exactly why this was here before 🤔 But I remember there was a reason

Brain dump:

Before your changes, if there was another request to fetch offerings, the request considers the cache to be recent and it would return that outdated offerings cache instead, but that's incorrect right? I think that's what you optimized.

Also, if there was an issue getting the offerings, we would clear that newly set timestamp, and the following request would retry, with your changes, the behavior is the same

Anyways, I think the optimization makes sense, I am just trying to remember why this was done that way lol

Copy link
Contributor Author

Choose a reason for hiding this comment

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

if there was another request to fetch offerings, the request considers the cache to be recent and it would return that outdated offerings cache instead, but that's incorrect right?

Mostly yes. However, the scenario I was trying to fix was the following:

  • User calls getOfferings BEFOREactivityonStart`. We mark timestamp of the cache to now before the requests starts
  • Activity onStart happens, however, the offerings cache already has a recent timestamp so we don't even try to get it (since it happens in the background, we don't expect a result, so we don't even check if there is a cached value or not).
  • We remain only with the request that had the jittering, rendering the main changes in this PR useless.

Also, if there was an issue getting the offerings, we would clear that newly set timestamp, and the following request would retry, with your changes, the behavior is the same

That's correct, and that remains that way for now, though it probably wouldn't be needed now. I've just made it so marking the offering cache timestamp happens only after a success.

The main consequence of this change is that multiple offerings request may happen before we receive a successful response, however, we rely on the request callback cache in the Backend to avoid duplicate requests.

Copy link
Member

Choose a reason for hiding this comment

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

We had a recent conversation about this on iOS as well. Using the "set timestamp to now" workaround is effective but definitely not intuitive and confusing for maintainers.

I think the ultimate solution to it is to have 2 values - "last updated" and "is currently updating", and use the combination of those 2 as a more expressive way of determining state. We're currently encoding both of those into a single boolean value.

The cache key came later, though, so maybe it removes the need for that workaround entirely? Does it de-duplicate requests with the same key? If so it effectively removes the need for the "set timestamp to now" workaround and even having an "is currently updating" value as well, right?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

The cache key came later, though, so maybe it removes the need for that workaround entirely? Does it de-duplicate requests with the same key?

Yes, the callback cache mechanism deduplicates requests while the request "is currently updating". However, we still need to store the last time the cache was updated to know whether it's stale, so we still need to store the timestamp on a success.

We had a recent conversation about this on iOS as well.

Yes, iOS did a similar change not too long ago and it relies on the request cache as well. I think that makes the most sense to handle deduplicating requests while it's in progress.

@tonidero tonidero requested a review from vegaro August 21, 2023 07:47
@tonidero tonidero marked this pull request as ready for review August 21, 2023 08:29
@tonidero tonidero requested a review from a team August 21, 2023 08:29
@tonidero tonidero merged commit c149125 into main Aug 22, 2023
7 checks passed
@tonidero tonidero deleted the remove-jitter-delay-callback-if-request-without-it branch August 22, 2023 07:15
Comment on lines +532 to +540
// In case we have a request with a jittered delay queued, and we perform the same request without
// jittered delay, we want to call the callback using the unjittered request
val backgroundedCacheKey = cacheKey.copy(appInBackground = true)
val backgroundCallAlreadyInPlace = containsKey(foregroundCacheKey)
if (!cacheKey.appInBackground && backgroundCallAlreadyInPlace) {
warnLog(NetworkStrings.SAME_CALL_SCHEDULED_WITH_JITTER.format(foregroundCacheKey))
remove(backgroundedCacheKey)?.takeIf { it.isNotEmpty() }?.let { backgroundedCallbacks ->
if (containsKey(cacheKey)) {
this[cacheKey]?.addAll(backgroundedCallbacks)
Copy link
Member

Choose a reason for hiding this comment

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

👏

@@ -61,11 +61,12 @@ internal class OfferingsManager(
onError: ((PurchasesError) -> Unit)? = null,
onSuccess: ((Offerings) -> Unit)? = null,
) {
offeringsCache.setOfferingsCacheTimestampToNow()
Copy link
Member

Choose a reason for hiding this comment

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

We had a recent conversation about this on iOS as well. Using the "set timestamp to now" workaround is effective but definitely not intuitive and confusing for maintainers.

I think the ultimate solution to it is to have 2 values - "last updated" and "is currently updating", and use the combination of those 2 as a more expressive way of determining state. We're currently encoding both of those into a single boolean value.

The cache key came later, though, so maybe it removes the need for that workaround entirely? Does it de-duplicate requests with the same key? If so it effectively removes the need for the "set timestamp to now" workaround and even having an "is currently updating" value as well, right?

@@ -94,6 +95,7 @@ internal class OfferingsManager(
},
onSuccess = { offerings ->
offeringsCache.cacheOfferings(offerings, offeringsJSON)
offeringsCache.setOfferingsCacheTimestampToNow()
Copy link
Member

Choose a reason for hiding this comment

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

I wonder if these two lines should just be a part of cacheOfferings in offeringsCache and guarantee atomicity there

@@ -94,6 +95,7 @@ internal class OfferingsManager(
},
onSuccess = { offerings ->
offeringsCache.cacheOfferings(offerings, offeringsJSON)
offeringsCache.setOfferingsCacheTimestampToNow()
Copy link
Member

Choose a reason for hiding this comment

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

Feels like it'd be conceptually cleaner and slightly easier to make it synchronized?

@@ -335,6 +336,60 @@ class BackendTest {
}
}

@Test
fun `given getCustomerInfo call on foreground, then one in background, only one request without delay is triggered`() {
Copy link
Member

Choose a reason for hiding this comment

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

we have to do this because it'd be tricky to cancel the existing request for the background, right?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Well, in this case I think it makes sense to reuse the existing callback cache, since we would get the result earlier without the jittering.

On the opposite side though (first request in background, then one in foreground), ideally we would cancel the background request, but cancelling exising requests would be very tricky yeah. However, since I think this case shouldn't be that common, I think it's better to just leave the request even if it's duplicated.

tonidero added a commit that referenced this pull request Aug 24, 2023
### Description
Unifies caching offerings and updating the offerings cache timestamp to
simplify things, as suggested in
#1204 (comment)
tonidero added a commit that referenced this pull request Aug 24, 2023
**This is an automatic release.**

### Performance Improvements
* Optimize SDK initialization when requests executed before any activity
starts (#1204) via Toni Rico (@tonidero)
* Optimize diagnostics file management (#1194) via Toni Rico (@tonidero)
### Other Changes
* Use real debug view dependencies in magic weather compose (#1203) via
Toni Rico (@tonidero)

---------

Co-authored-by: revenuecat-ops <ops@revenuecat.com>
Co-authored-by: Toni Rico <antonio.rico.diez@revenuecat.com>
Co-authored-by: Toni Rico <toni.rico.diez@revenuecat.com>
tonidero added a commit that referenced this pull request Nov 9, 2023
### Description
These logs were added in
#1204. These logs
might happen primarily if the developer calls
getCustomerInfo/getOfferings before any activity has started. In those
situations, we have to handle it so the request does not wait for the
whole jitter.

There is nothing much the developer can do about this and it's not
actually an error, so lowering the log level from warning to debug for
these.

Fixes RevenueCat/react-native-purchases#757
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
perf A code change that improves performance
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

3 participants