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

TransactionPoster: avoid posting transactions multiple times #2914

Closed
wants to merge 5 commits into from

Conversation

NachoSoto
Copy link
Contributor

@NachoSoto NachoSoto commented Jul 28, 2023

This is a performance optimization that will help avoid duplicate posts after #2612.

TODO:

  • Tests for finishing (if posted) / not finishing
  • Observer mode does not finish if already posted
  • Test for get customer info + already posted avoids infinite recursion
  • Integration tests

@NachoSoto NachoSoto added the perf A code change that improves performance label Jul 28, 2023
@NachoSoto NachoSoto requested a review from a team July 28, 2023 12:36
@NachoSoto NachoSoto marked this pull request as draft July 28, 2023 12:36
@NachoSoto NachoSoto force-pushed the cache-posted-transactions-2 branch 2 times, most recently from 6531ed1 to 97ac084 Compare August 3, 2023 21:47
@NachoSoto NachoSoto changed the base branch from cache-posted-transactions to customer-info-post-transactions-dedup August 3, 2023 21:47
@codecov
Copy link

codecov bot commented Aug 3, 2023

Codecov Report

Merging #2914 (8d1d751) into customer-info-post-transactions-dedup (8d1d751) will not change coverage.
The diff coverage is n/a.

❗ Current head 8d1d751 differs from pull request most recent head c64653e. Consider uploading reports for the commit c64653e to get more accurate results

@@                          Coverage Diff                           @@
##           customer-info-post-transactions-dedup    #2914   +/-   ##
======================================================================
  Coverage                                  86.64%   86.64%           
======================================================================
  Files                                        217      217           
  Lines                                      15536    15536           
======================================================================
  Hits                                       13461    13461           
  Misses                                      2075     2075           

@NachoSoto NachoSoto marked this pull request as ready for review August 3, 2023 22:30
@NachoSoto
Copy link
Contributor Author

This is ready now

self.paymentQueueWrapper = paymentQueueWrapper
self.systemInfo = systemInfo
self.operationDispatcher = operationDispatcher
}

// TODO: consumables
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Gotta consider how this works with #2841.

Copy link
Member

Choose a reason for hiding this comment

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

maybe we should get both into an integration branch before shipping and test them together?

Sources/Purchasing/Purchases/TransactionPoster.swift Outdated Show resolved Hide resolved
expect(self.cache.hasPostedTransaction(transaction)) == true
}

func testSaveMultipleTransactions() {
Copy link
Member

Choose a reason for hiding this comment

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

we should add a test to verify the behavior if the same transaction gets posted over and over (like if it's not finished)

Copy link
Member

Choose a reason for hiding this comment

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

basically to ensure that we don't grow the cache forever

Copy link
Contributor Author

Choose a reason for hiding this comment

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

It's stored as a Set, but yeah I can add that.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done:

func testHandlePurchasedTransactionMultipleTimesPostsItOnce() throws {
    let transactionData = PurchasedTransactionData(
        appUserID: "user",
        source: .init(isRestore: false, initiationSource: .queue)
    )
    let count = 10

    self.backend.stubbedPostReceiptResult = .success(self.createCustomerInfo(nonSubscriptionProductID: nil))

    for _ in 0..<count {
        _ = try self.handleTransaction(transactionData)
    }

    expect(self.backend.invokedPostReceiptDataCount) == 1
    expect(self.mockTransaction.finishInvokedCount) == count

    expect(self.cache.postedTransactions) == [self.mockTransaction.transactionIdentifier]
}

expect(parameters.data.source.initiationSource) == .queue
}

func testFetchesCustomerInfoIfTransactionWasAlreadyPostedButNoCachedCustomerInfo() async throws {
Copy link
Member

Choose a reason for hiding this comment

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

🤔
This is a weird case to think about. Not sure fetching customer info is the best path here?

I can only imagine arriving here if:

  • we've posted the transaction before
  • we call invalidate customer info cache
  • we get notified again for an unfinished transaction

Or similar thinking but someone calls restoreCompletedTransactions.

Feels like it'd be safer to just post in this case? Would that be a huge refactor? WDYT?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

That complicates the code unnecessarily. I'd have to give TransactionPoster knowledge of whether there is a cached customer info or not.

@NachoSoto NachoSoto force-pushed the customer-info-post-transactions-dedup branch from 8d1d751 to e214dfe Compare August 7, 2023 23:06
@@ -1088,7 +1090,7 @@ private extension PurchasesOrchestrator {
if let completion = self.getAndRemovePurchaseCompletedCallback(forTransaction: purchasedTransaction) {
self.operationDispatcher.dispatchOnMainActor {
completion(purchasedTransaction,
result.value,
Copy link
Contributor Author

@NachoSoto NachoSoto Aug 8, 2023

Choose a reason for hiding this comment

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

There's a possibility that this ends up with nil customer info and nil error, leading to a crash: https://app.circleci.com/pipelines/github/RevenueCat/purchases-ios/13951/workflows/3b380d52-bc83-4449-aa91-cf6b55235ec8/jobs/103818

Thread 0 Crashed:
0   libswiftCore.dylib            	       0x1021b1e81 _assertionFailure(_:_:file:line:flags:) + 353
1   RevenueCat                    	       0x1310b2dc7 Result.init(_:_:file:line:) + 1047 (Result+Extensions.swift:24)
2   RevenueCat                    	       0x130f35cf9 closure #1 in closure #1 in Purchases.purchaseAsync(product:promotionalOffer:) + 361 (Purchases+async.swift:97)
3   RevenueCat                    	       0x1310dcd9f closure #2 in PurchasesOrchestrator.purchase(sk1Product:payment:package:wrapper:completion:) + 847 (PurchasesOrchestrator.swift:395)
4   RevenueCat                    	       0x1310ec8dc partial apply for closure #2 in PurchasesOrchestrator.purchase(sk1Product:payment:package:wrapper:completion:) + 44
5   RevenueCat                    	       0x1310e4ce9 thunk for @escaping @callee_guaranteed @Sendable (@guaranteed StoreTransaction?, @guaranteed CustomerInfo?, @guaranteed NSError?, @unowned Bool) -> () + 25
6   RevenueCat                    	       0x1310e8806 thunk for @escaping @callee_guaranteed @Sendable (@in_guaranteed StoreTransaction?, @in_guaranteed CustomerInfo?, @in_guaranteed NSError?, @in_guaranteed Bool) -> (@out ()) + 134
7   RevenueCat                    	       0x1310e4ce9 thunk for @escaping @callee_guaranteed @Sendable (@guaranteed StoreTransaction?, @guaranteed CustomerInfo?, @guaranteed NSError?, @unowned Bool) -> () + 25
8   RevenueCat                    	       0x1310e8806 thunk for @escaping @callee_guaranteed @Sendable (@in_guaranteed StoreTransaction?, @in_guaranteed CustomerInfo?, @in_guaranteed NSError?, @in_guaranteed Bool) -> (@out ()) + 134
9   RevenueCat                    	       0x1310eb387 closure #1 in closure #1 in PurchasesOrchestrator.handlePurchasedTransaction(_:storefront:restored:) + 759 (PurchasesOrchestrator.swift:1092)
10  RevenueCat                    	       0x130edc340 closure #1 in static OperationDispatcher.dispatchOnMainActor(_:) + 80 (OperationDispatcher.swift:60)
11  RevenueCat                    	       0x130edc5d1 partial apply for closure #1 in static OperationDispatcher.dispatchOnMainActor(_:) + 1
12  RevenueCat                    	       0x130edc3e1 thunk for @escaping @callee_guaranteed @Sendable @async () -> () + 1
13  RevenueCat                    	       0x130edc6e1 partial apply for thunk for @escaping @callee_guaranteed @Sendable @async () -> () + 1
14  RevenueCat                    	       0x130e6bbe1 thunk for @escaping @callee_guaranteed @Sendable @async () -> (@out A) + 1
15  RevenueCat                    	       0x130e6bd11 partial apply for thunk for @escaping @callee_guaranteed @Sendable @async () -> (@out A) + 1

Need to write a unit test for this and fix it.

Copy link
Contributor Author

@NachoSoto NachoSoto Aug 15, 2023

Choose a reason for hiding this comment

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

Fixed and tested.

Copy link
Contributor

@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.

Looks pretty good, just a comment

Sources/Identity/CustomerInfoManager.swift Outdated Show resolved Hide resolved
@NachoSoto NachoSoto force-pushed the customer-info-post-transactions-dedup branch from e214dfe to 75f5734 Compare August 11, 2023 20:46
Base automatically changed from customer-info-post-transactions-dedup to main August 11, 2023 22:39
NachoSoto added a commit that referenced this pull request Aug 14, 2023
See #2914 (comment)
This will be used by that PR as a fallback whenever we try to post a transaction that had already been posted.
}
}

self.handleReceiptPost(
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 uses handleReceiptPost which simplifies handling .alreadyPosted.

case success(CustomerInfo)

/// Transaction had already been posted.
case alreadyPosted
Copy link
Contributor Author

Choose a reason for hiding this comment

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

New type helps ensure that we handle this scenario everywhere that used Result<CustomerInfo, BackendError>.

for: product,
customerInfo: customerInfo
) {
self.finishTransactionIfNeeded(transaction) {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

A subtle change here, suggested by @aboedo: we no longer call finishTransactionIfNeeded from the main actor, we only call the completion (see complete) above block after we're done with everything.

expect(parameters.data.source.initiationSource) == .queue
}

func testPostsFirstUnpostedTransaction() async throws {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Updated this test to have some previously posted transactions too.

let payment = SKPayment(product: self.product)

let customerInfoBeforePurchase = try CustomerInfo(data: [
self.customerInfoBeforePurchase = try CustomerInfo(data: [
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 extracting these 2 CustomerInfos to avoid duplicate code.

Comment on lines +119 to +120
try self.delegate.storeKit1Wrapper(self.storeKit1Wrapper, updatedTransaction: transaction1)
try self.delegate.storeKit1Wrapper(self.storeKit1Wrapper, updatedTransaction: transaction2)
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 test no longer had the same semantics because it used the same transaction, which was a shortcut. I made this reflect a more normal flow, and added another one below that uses the same transaction, for which the second one we don't post

Comment on lines +53 to +55
expect(result.error?.finishable) == false

expect(self.cache.postedTransactions).to(beEmpty())
Copy link
Contributor Author

Choose a reason for hiding this comment

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

testHandlePurchasedTransactionWithMissingReceipt is basically testing a not-finishable error. In this case we don't save the posted transaction.

Comment on lines +70 to +72
expect(result.error?.finishable) == true

self.verifyTransactionWasCached()
Copy link
Contributor Author

Choose a reason for hiding this comment

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

For finishable errors we do.

@@ -64,13 +84,57 @@ class TransactionPosterTests: TestCase {
self.backend.stubbedPostReceiptResult = .success(Self.mockCustomerInfo)

let result = try self.handleTransaction(transactionData)
expect(result).to(beSuccess())
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 isn't a Result anymore, so can't use beSuccess(). But checking .value is equivalent.

Comment on lines +130 to +153
for _ in 0..<count {
_ = try self.handleTransaction(transactionData)
}

expect(self.backend.invokedPostReceiptDataCount) == 1
expect(self.mockTransaction.finishInvokedCount) == count
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Calling it 10 times to ensure that we only post it once (and finish 10 times).


expect(self.backend.invokedPostReceiptData) == true
expect(self.backend.invokedPostReceiptDataParameters?.observerMode) == true
expect(self.mockTransaction.finishInvoked) == false

self.verifyTransactionWasCached()
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Added these to all tests that are relevant.

self.serverDown()
try await self.purchaseMonthlyProduct()

self.logger.verifyMessageWasLogged(Strings.offlineEntitlements.computing_offline_customer_info, level: .info)
self.verifyNoTransactionsWereFinished()
self.verifyNoTransactionsWereStored()
Copy link
Contributor Author

@NachoSoto NachoSoto Aug 15, 2023

Choose a reason for hiding this comment

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

I actually broke this (thankfully that broke other tests), so now I'm covering it with a test.

Comment on lines +63 to +64
self.verifyTransactionWasFinished(count: 1)
self.verifyTransactionWasStored(count: 1)
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Improved the basic "can purchase package" test with these 2 assertions.

Copy link
Contributor

@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.

Left a few comments but nothing major. I think this looks good.

@@ -361,6 +374,21 @@ private extension CustomerInfoManager {
}
}

private func cachedOrRequestCustomerInfoFetcher(
Copy link
Contributor

Choose a reason for hiding this comment

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

Hmm this seems unused now after the latest changes?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Thanks, removed!


/// Converts the `TransactionPosterResult` into `Result<CustomerInfo, BackendError>`
/// by using `customerInfoFetcher` if the result is `.alreadyPosted`.
func toResult(
Copy link
Contributor

Choose a reason for hiding this comment

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

The comment is clear but I'm concerned the name of the function doesn't indicate that this might initiate a fetch of the customer info... Maybe we could rename to toResultOrFetchCustomerInfo? Or something along those lines?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Swift (and Obj-C) are different from other languages, the name of this method isn't just toResult, it's toResult(completion:customerInfoFetcher:), so the "fetch customer info" part is already part of the name.
It would have to toResultOrFetchCustomerInfo(completion:_:) which is less clear IMO.

// MARK: -

private var storedTransactions: StoredTransactions {
return self.deviceCache.value(for: CacheKey.transactions) ?? []
Copy link
Contributor

Choose a reason for hiding this comment

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

Kinda unrelated to this PR but I was wondering, do we clear the cache values at any point? In android we clear them when we have a token but not a corresponding active purchase anymore (so the purchase has expired). We perform this check on every app foreground.

Copy link
Member

Choose a reason for hiding this comment

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

That's a fair question. There's no direct equivalent on iOS, in that a purchase never goes away from the receipt (other than in Sandbox).

We could give them a TTL based on expiration date, with the assumption that if a purchase has been posted and finished, if say, 90 days go by, you're probably not gonna find it in the queue again and should be safe to remove.

It's not perfectly true in that calling restoreCompletedTransactions could technically bring it back, but should work for the vast majority of cases. Thoughts?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done in #3023 and rebased this.

NachoSoto added a commit that referenced this pull request Aug 16, 2023
This was suggested in #2914 as a way to prevent the cache from growing forever.

### Other changes:
- Added `PostedTransactionCache.unpostedTransactions`: this was added in #2914, so I put it here to prevent merge conflicts.
- `PostedTransactionCache` now verifies that cache interaction doesn't happen in the main thread
- Added debug log when saving transactions
- Added debug log when pruning cache
- Added `ClockType` tests
@NachoSoto NachoSoto changed the base branch from main to transaction-cache-ttl August 16, 2023 17:24
@NachoSoto NachoSoto requested a review from aboedo August 16, 2023 17:24
self.handlePostReceiptResult(result,
subscriberAttributes: unsyncedAttributes,
adServicesToken: adServicesToken)
self.operationDispatcher.dispatchOnWorkerThread {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

⚠️ Another change here. We were handling transactions in the main thread without this, which now made us read/write cache in the main thread.
Thanks to the new assertions we're avoiding this now.

@NachoSoto
Copy link
Contributor Author

Closing this. As shown in #3020 this won't work with that 😢

@NachoSoto NachoSoto closed this Aug 16, 2023
NachoSoto added a commit that referenced this pull request Aug 16, 2023
As explained in #2914 this won't be usable.
@NachoSoto NachoSoto deleted the cache-posted-transactions-2 branch August 28, 2023 18:29
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 PostReceipt optimizations
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

4 participants