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
Add jitter and extra cache for background processes #366
Add jitter and extra cache for background processes #366
Conversation
…running in the background.
|
||
@interface RCDeviceCache () | ||
|
||
@property (nonatomic) NSUserDefaults *userDefaults; | ||
@property (nonatomic) NSNotificationCenter *notificationCenter; | ||
@property (nonatomic, nonnull) RCInMemoryCachedObject<RCOfferings *> *offeringsCachedObject; | ||
@property (nonatomic, nullable) NSDate *purchaserInfoCachesLastUpdated; |
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 were storing this value in memory before. This doesn't work well for widgets, since the process for the widget will likely be killed before the next refresh, so it'll always refresh cache.
#import "RCLogUtils.h" | ||
#import "NSDictionary+RCExtensions.h" |
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.
unused imports
@@ -33,21 +33,21 @@ NS_ASSUME_NONNULL_BEGIN | |||
|
|||
- (void)cachePurchaserInfo:(NSData *)data forAppUserID:(NSString *)appUserID; | |||
|
|||
- (BOOL)isPurchaserInfoCacheStale; | |||
- (BOOL)isPurchaserInfoCacheStaleForAppUserID:(NSString *)appUserID isAppBackgrounded:(BOOL)isAppBackgrounded; |
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.
not a huge fan of how this API is turning out, it feels overly complicated. But I didn't want to perform a major refactor for this.
@@ -26,11 +23,12 @@ @interface RCDeviceCache () | |||
NSString *RCLegacyGeneratedAppUserDefaultsKey = RC_CACHE_KEY_PREFIX @".appUserID"; | |||
NSString *RCAppUserDefaultsKey = RC_CACHE_KEY_PREFIX @".appUserID.new"; | |||
NSString *RCPurchaserInfoAppUserDefaultsKeyBase = RC_CACHE_KEY_PREFIX @".purchaserInfo."; | |||
NSString *RCPurchaserInfoLastUpdatedKeyBase = RC_CACHE_KEY_PREFIX @".purchaserInfoLastUpdated."; |
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.
new userDefaults
entry
@@ -125,34 +122,55 @@ - (void)cachePurchaserInfo:(NSData *)data forAppUserID:(NSString *)appUserID { | |||
@synchronized (self) { | |||
[self.userDefaults setObject:data | |||
forKey:[self purchaserInfoUserDefaultCacheKeyForAppUserID:appUserID]]; | |||
[self setPurchaserInfoCacheTimestampToNow]; | |||
[self setPurchaserInfoCacheTimestampToNowForAppUserID:appUserID]; |
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 didn't used to be tied to an appUserID
, but I tied it now for the sake of consistency.
@@ -163,8 +181,9 @@ - (void)cacheOfferings:(RCOfferings *)offerings { | |||
[self.offeringsCachedObject cacheInstance:offerings]; | |||
} | |||
|
|||
- (BOOL)isOfferingsCacheStale { | |||
return self.offeringsCachedObject.isCacheStale; | |||
- (BOOL)isOfferingsCacheStaleWithIsAppBackgrounded:(BOOL)isAppBackgrounded { |
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.
having a different cache might not be strictly necessary for offerings, really - it's unlikely that you'd need offerings while the app is running in the background. can't hurt, though.
Purchases/Public/RCPurchases.m
Outdated
[self.operationDispatcher dispatchOnWorkerThread:^{ | ||
[self.operationDispatcher dispatchOnWorkerThreadWithRandomDelay:NO :^{ |
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 value for randomDelay
is always isBackgrounded
, it might make sense to have the operationDispatcher
depend on systemInfo and get the value without a param
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 actually think the param is better. That way we don't add logic to the operationDispatcher that depends on the state of the system. The operation dispatcher only knows what it means to add a random delay, it doesn't care if the app is backgrounded or not. Making operationDispatcher
depend on the system info also means that you don't have flexibility to do the random delay only for getOfferings or getPurchaserInfo
[self.systemInfo isApplicationBackgroundedWithCompletion:^(BOOL isAppBackgrounded) { | ||
RCPurchaserInfo *infoFromCache = [self readPurchaserInfoFromCache]; | ||
if (infoFromCache) { | ||
RCDebugLog(@"Vending purchaserInfo from cache"); | ||
CALL_IF_SET_ON_MAIN_THREAD(completion, infoFromCache, nil); | ||
if ([self.deviceCache isPurchaserInfoCacheStaleForAppUserID:self.appUserID isAppBackgrounded:isAppBackgrounded]) { | ||
RCDebugLog(@"Cache is stale, updating caches"); | ||
[self fetchAndCachePurchaserInfoWithCompletion:nil isAppBackgrounded:isAppBackgrounded]; | ||
} | ||
} else { | ||
RCDebugLog(@"No cached purchaser info, fetching"); | ||
[self fetchAndCachePurchaserInfoWithCompletion:completion isAppBackgrounded:isAppBackgrounded]; |
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.
really don't love making these even more complex, but I couldn't think of a way around it given that the isAppBackgrounded method is async.
[self.systemInfo isApplicationBackgroundedWithCompletion:^(BOOL isAppBackgrounded) { | ||
if ([self.deviceCache isPurchaserInfoCacheStaleForAppUserID:self.appUserID isAppBackgrounded:isAppBackgrounded]) { | ||
RCDebugLog(@"PurchaserInfo cache is stale, updating caches"); | ||
[self fetchAndCachePurchaserInfoWithCompletion:nil isAppBackgrounded:isAppBackgrounded]; | ||
} | ||
if ([self.deviceCache isOfferingsCacheStaleWithIsAppBackgrounded:isAppBackgrounded]) { | ||
RCDebugLog(@"Offerings cache is stale, updating caches"); | ||
[self updateOfferingsCache:nil isAppBackgrounded:isAppBackgrounded]; | ||
} | ||
}]; |
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.
would love to move all this stuff into a new class, but it feels too risky for this particular feature
Purchases/Public/RCPurchases.m
Outdated
CALL_IF_SET_ON_MAIN_THREAD(completion, info, error); | ||
}]; | ||
[self.deviceCache setPurchaserInfoCacheTimestampToNowForAppUserID:appUserID]; | ||
[self.operationDispatcher dispatchOnWorkerThreadWithRandomDelay:isAppBackgrounded :^{ |
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 API came out a bit weird - note the nameless second param that just starts with :
…lastUpdatedAt from userDefaults
func testNewDeviceCacheInstanceWithExistingValidPurchaserInfoCacheIsntStale() { | ||
let mockNotificationCenter = MockNotificationCenter() | ||
let appUserID = "myUser" | ||
let fourMinutesAgo = Calendar.current.date(byAdding: .minute, value: -4, to: Date()) | ||
mockUserDefaults.mockValues["com.revenuecat.userdefaults.purchaserInfoLastUpdated.\(appUserID)"] = fourMinutesAgo | ||
self.deviceCache = RCDeviceCache(mockUserDefaults, | ||
offeringsCachedObject: nil, | ||
notificationCenter: mockNotificationCenter) | ||
|
||
expect(self.deviceCache.isPurchaserInfoCacheStale(forAppUserID: appUserID, | ||
isAppBackgrounded: false)) == false | ||
} | ||
|
||
func testNewDeviceCacheInstanceWithExistingInvalidPurchaserInfoCacheIsStale() { | ||
let mockNotificationCenter = MockNotificationCenter() | ||
let appUserID = "myUser" | ||
let fourDaysAgo = Calendar.current.date(byAdding: .day, value: -4, to: Date()) | ||
mockUserDefaults.mockValues["com.revenuecat.userdefaults.purchaserInfoLastUpdated.\(appUserID)"] = fourDaysAgo | ||
self.deviceCache = RCDeviceCache(mockUserDefaults, | ||
offeringsCachedObject: nil, | ||
notificationCenter: mockNotificationCenter) | ||
|
||
expect(self.deviceCache.isPurchaserInfoCacheStale(forAppUserID: appUserID, | ||
isAppBackgrounded: false)) == true | ||
} | ||
|
||
func testNewDeviceCacheInstanceWithNoCachedPurchaserInfoCacheIsStale() { | ||
let mockNotificationCenter = MockNotificationCenter() | ||
let appUserID = "myUser" | ||
self.deviceCache = RCDeviceCache(mockUserDefaults, | ||
offeringsCachedObject: nil, | ||
notificationCenter: mockNotificationCenter) | ||
|
||
expect(self.deviceCache.isPurchaserInfoCacheStale(forAppUserID: appUserID, | ||
isAppBackgrounded: false)) == true |
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.
these would have failed in develop
since we used to have the purchaserInfoUpdatedAt
value in memory and not userDefaults
@@ -299,7 +299,7 @@ - (instancetype)initWithAppUserID:(nullable NSString *)appUserID | |||
|
|||
[self.systemInfo isApplicationBackgroundedWithCompletion:^(BOOL isBackgrounded) { | |||
if (!isBackgrounded) { | |||
[self.operationDispatcher dispatchOnWorkerThread:^{ | |||
[self.operationDispatcher dispatchOnWorkerThreadWithRandomDelay:NO block:^{ |
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.
What do you think about keeping around dispatchOnWorkerThread
in operationDispatcher
and make it call dispatchOnWorkerThreadWithRandomDelay:NO
. I like it more since most of the times we will be passing NO
.
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.
yeah, I'll separate into two methods. I'd started by doing that, then I moved to having the randomDelay be optional, then the optional randomDelay looked great in swift but ported very poorly to obj-c, so I killed the optionality. I'll go back to the start.
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.
ok, I was trying this and remembered why I ended up going with the param: it's so that callers don't have to choose between methods, since almost every time they pass in the value of isBackgrounded
.
So if there are two separate methods, you'd have to do if isBackgrounded dispatchWithRandomDelay else dispatch
.
note that we almost every time pass isBackgrounded
. even in this line, we're passing NO
because we know that isBackgrounded
is false, because of the if
in the line right above. But we could just as easily pass isBackgrounded
instead.
[self.deviceCache setOfferingsCacheTimestampToNow]; | ||
__weak typeof(self) weakSelf = self; |
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 remember you added this a while ago. We don't need it anymore?
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 think we needed it in the first place - I believe I made a mistake in thinking that there was a retention cycle there, but really, the block is used only here and the completion block isn't stored elsewhere, so as soon as the completion is executed, it dies and the block is released, then the instance is ready to be released.
The case handled here is not trivial, though:
if you call reset after calling offerings and the callback hasn't returned:
- if you're doing this weak referencing (current case), the purchases instance is killed, it no longer holds on to the completion block, and completion isn't called.
- if you're doing this with strong referencing (it's the default, and what we do for almost all other methods), the purchases instance lives until the completion block is called, which might lead to some unexpected behavior if there are any values in memory for the instance that are outdated after the reset.
I think we should at some point actually audit this behavior and check out what happens after a reset with some of these completion blocks.
As of right now, I'm not sure completion not returning is better or worse than it returning but potentially messing up if there's anything outdated in memory from the previous purchases instance.
expect(self.mockUserDefaults.mockValues["com.revenuecat.userdefaults.purchaserInfo.cesar"] as? Data) | ||
.to(equal(data)) | ||
expect(self.deviceCache.cachedPurchaserInfoData(forAppUserID: "cesar")).to(equal(data)) | ||
expect(self.mockUserDefaults.setObjectForKeyCalledValue) |
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 don't want to check for this anymore?
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 check on 153 is redundant with the one that's right above it, and the check in 154 actually assumes that the last thing you store is the purchaserInfo, which breaks when you store the purchaserInfoUpdatedDate
|
||
expect(self.deviceCache.isPurchaserInfoCacheStale(forAppUserID: appUserID, | ||
isAppBackgrounded: false)) == false | ||
} |
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.
What's the difference between this test and testIsPurchaserInfoCacheStaleForForeground
. Also, in this test you're calling mockUserDefaults.mockValues
but in the other tests you are setting it through setPurchaserInfoCacheTimestamp
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'll take any naming suggestions I can get, but here's what they're supposed to check:
testIsPurchaserInfoCacheStaleForForeground
:
test that the value of isPurchaserInfoCacheStale works correctly when the app is in the foreground.
- create a cached purchaser info, but stale date.
- value should be true
- update the purchaserInfo cache date, with a recent value
- value should be false.
testNewDeviceCacheInstanceWithExistingValidPurchaserInfoCacheIsntStale
is supposed to ensure that new instances won't return that the value is stale unless it actually is. This is because we previously were storing the lastUpdated value in memory, so this would have had a different result before this PR.
- store a value for lastUpdated, that's recent
- check that it isn't considered stale
As for using the two mechanisms to mock, they can be unified. I had just forgotten that you could set it directly through the protected method.
Using both mechanisms might be better coverage? but less consistent. I'm happy to unify.
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 ok, both mechanisms work. I wasn't sure I was missing somehting. Thanks for the explanation
mockCachedObject.updateCacheTimestamp(with: cacheDateValidForBoth) | ||
expect(self.deviceCache.isOfferingsCacheStale(withIsAppBackgrounded: false)) == false | ||
expect(self.deviceCache.isOfferingsCacheStale(withIsAppBackgrounded: true)) == false | ||
} |
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 feel we need a test for the migration case where the purchaser info timestamp is nil, but the cached purchaser info is not. This can happen for people updating from an older version. I don't think it's covered in any of these tests
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.
good catch
expect(self.mockBackend.invokedGetSubscriberDataCount) == 1 | ||
expect(self.mockSubscriberAttributesManager.invokedSyncAttributesForAllUsersCount) == 0 | ||
expect(self.mockDeviceCache.cachePurchaserInfoCount) == 1 |
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.
Isn't this line the same as the next one?
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.
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.
they're actually different, although the naming could be improved - one is for the read
method and the other one for the write
NSTimeInterval timeSinceLastCheck = -[purchaserInfoCachesLastUpdated timeIntervalSinceNow]; | ||
int cacheDurationInSeconds = [self cacheDurationInSecondsWithIsAppBackgrounded:isAppBackgrounded]; | ||
return !(purchaserInfoCachesLastUpdated != nil && timeSinceLastCheck < cacheDurationInSeconds); | ||
return timeSinceLastCheck >= cacheDurationInSeconds; |
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.
much easier to read
* added a separate value for cache duration to be used when the app is running in the background. * added jitter for cache updates that start with the app backgrounded. * added purchaserInfoLastUpdatedAt to userDefaults * fixed missing param * fixed some tests * fixed more tests * fixed another test * cleaned up the interface for dispatchWithRandomDelay * added tests to ensure that newly-created device cache instances read lastUpdatedAt from userDefaults * added more tests for device cache * even more device cache tests * added some new test cases to purchasesTests * added test to ensure we don't automatically fetch purchaser info onDidBecomeActive unless cache is stale * updated maxJitterInSeconds to 5 * formatting of * _Nullable * simplified conditions and improved nil handling in isPurchaserInfoCacheStaleForAppUserID * small cleanup * added test case for migration
Apps opening from push notifications and potentially widgets may cause unnecessary strain on the backend by making calls simultaneously on all devices.
To alleviate the backend strain, we want to introduce a small, random delay to cache updates performed while the app is in the background. This will distribute requests over time so that the backend isn’t hit at once.
We’re also going to be more aggressive with the cache while the app is in the background, moving it from 5 mins to 1 day.
The value of 5 mins will still be used if the app is in the foreground.
Ruleset: