-
Notifications
You must be signed in to change notification settings - Fork 45
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
Optimize SDK initialization when requests executed before any activity starts #1204
Conversation
…s after non-jittered calls
There was a problem hiding this 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() |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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, |
There was a problem hiding this comment.
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 Report
@@ 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
|
delay: Delay = Delay.NONE, | ||
) { | ||
val foregroundCacheKey = Pair(cacheKey.first, false) | ||
val cacheKeyToUse = if (cacheKey.second && callbacks.containsKey(foregroundCacheKey)) { |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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 👍
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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() |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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
BEFOREactivity
onStart`. 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.
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
// 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) |
There was a problem hiding this comment.
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() |
There was a problem hiding this comment.
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() |
There was a problem hiding this comment.
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() |
There was a problem hiding this comment.
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`() { |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
### Description Unifies caching offerings and updating the offerings cache timestamp to simplify things, as suggested in #1204 (comment)
**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>
### 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
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 backgroundThe approach in this PR is to make it so:
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