Search code examples
iosswiftgoogle-cloud-firestoregrand-central-dispatchdispatch-async

dispatchgroup executes task in different order in testflight compared to simulator


So my goal is to have congruent functionality both on the iOS simulator in Xcode and as well as a physical device on TestFlight. So currently, I have a function that handles refunds in my app. On the simulator the function runs perfectly fine in the order I expect it to, but the print statements execute in the wrong order which I'm assuming is the reason for misbehaviour on TestFlight simulations.

Here is the method:

@IBAction func cancelPurchasePressed(_ sender: UIButton) {
    guard let nameOfEvent = selectedEventName else { return }
    guard let user = Auth.auth().currentUser else { return }

    
    let alertForCancel = UIAlertController(title: "Cancel Purchase", message: "Are you sure you want to cancel your purchase of a ticket to \(nameOfEvent)? You will receive full reimbursement of what you paid within 5 - 10 days.", preferredStyle: .alert)
    
    
    let cancelPurchase = UIAlertAction(title: "Cancel Purchase", style: .default) { (purchaseCancel) in
        
        self.viewPurchaseButton.isHidden = true
        self.cancelPurchaseButton.isHidden = true
        self.refundLoading.alpha = 1
        self.refundLoading.startAnimating()
        
        self.makeRefundRequest()
        
       
        DispatchQueue.main.asyncAfter(deadline: .now()+1) {
            let group = DispatchGroup()
            self.db.collection("student_users/\(user.uid)/events_bought/\(nameOfEvent)/guests").getDocuments { (querySnapshot, error) in
                guard error == nil else {
                    print("The guests couldn't be fetched.")
                    return
                }
                guard querySnapshot?.isEmpty == false else {
                    print("The user did not bring any guests.")
                    return
                }
                for guest in querySnapshot!.documents {
                    let name = guest.documentID
                    group.enter()
                    self.db.document("student_users/\(user.uid)/events_bought/\(nameOfEvent)/guests/\(name)").delete { (error) in
                        guard error == nil else {
                            print("The guests couldn't be deleted.")
                            return
                        }
                        print("Guests deleted with purchase refund.")
                        group.leave()
                    }
                }
            }
            group.notify(queue: .main) {
                
                self.db.document("student_users/\(user.uid)/events_bought/\(nameOfEvent)").delete { (error) in
                    guard error == nil else {
                        print("Error trying to delete the purchased event.")
                        return
                    }
                    print("The purchased event was succesfully removed from the database!")
                }
                self.refundLoading.stopAnimating()
                self.refundLoading.alpha = 0
                self.ticketFormButton.isHidden = false
                self.cancelPurchaseButton.isHidden = true
                self.viewPurchaseButton.isHidden = true
            }
        }
    }
    
    alertForCancel.addAction(UIAlertAction(title: "Cancel", style: .cancel))
    alertForCancel.addAction(cancelPurchase)
    present(alertForCancel, animated: true, completion: nil)
}

Basically what I have going on is a simple refund request being made to Stripe and a second after I have an asyncAfter code block with some database cleanup in it. I have to do the asyncAfter or else the refund request gets beat out by the other async tasks by speed.

So I took my knowledge of DispatchGroups and decided to implement it since I have an async task in a for loop that I need to be completed before every other task. So I expected this to work fine, despite the order of the print statements being incorrect, but when I ran the exact block of code on my phone via TestFlight, I made a refund and the cell was still showing up in the tableview, meaning the document wasn't deleted from the database properly.

I've been having some terrifying experience recently with DispatchGroups and TestFlight and I just honestly hope to fix all this and have these problems come to an end temporarily. Any suggestions on how I can fix this method to prevent incorrect order on TestFlight?

UPDATE Decided to use a completion handler instead to do the same functionality:

func makeRefundRequest(refundMade: @escaping ((Bool) -> ())) {
    let backendURLForRefund = "https://us-central1-xxxxxx-41f12.cloudfunctions.net/createRefund"
    getStripePaymentIntentID { (paymentid) in
            guard let id = paymentid else { return }
            let url = URL(string: backendURLForRefund)!
            let json: [String: Any] = [
            
                "payment_intent": id
            
            ]
            var request = URLRequest(url: url)
            request.httpMethod = "POST"
            request.setValue("application/json", forHTTPHeaderField: "Content-Type")
            request.httpBody = try? JSONSerialization.data(withJSONObject: json)
            let task = URLSession.shared.dataTask(with: request) { [weak self] (data, response, error) in
                guard let taskError = error?.localizedDescription else { return }
                guard let response = response as? HTTPURLResponse,
                      response.statusCode == 200,
                      let data = data,
                      let _ = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] else {
                    self?.showAlert(title: "Refund Request Error", message: "There was an error making the refund request. \(taskError)")
                    refundMade(false)
                    return
                }
            }
        task.resume()
    refundMade(true)
    }
}

And then I just slapped this method in the actual refund process method itself:

@IBAction func cancelPurchasePressed(_ sender: UIButton) {
    guard let nameOfEvent = selectedEventName else { return }
    guard let user = Auth.auth().currentUser else { return }

    
    let alertForCancel = UIAlertController(title: "Cancel Purchase", message: "Are you sure you want to cancel your purchase of a ticket to \(nameOfEvent)? You will receive full reimbursement of what you paid within 5 - 10 days.", preferredStyle: .alert)
    
    
    let cancelPurchase = UIAlertAction(title: "Cancel Purchase", style: .default) { (purchaseCancel) in
        
        self.viewPurchaseButton.isHidden = true
        self.cancelPurchaseButton.isHidden = true
        self.refundLoading.alpha = 1
        self.refundLoading.startAnimating()
        
        self.makeRefundRequest { (response) in
            if response == false {
                return
            } else {
                let group = DispatchGroup()
                self.db.collection("student_users/\(user.uid)/events_bought/\(nameOfEvent)/guests").getDocuments { (querySnapshot, error) in
                    guard error == nil else {
                        print("The guests couldn't be fetched.")
                        return
                    }
                    guard querySnapshot?.isEmpty == false else {
                        print("The user did not bring any guests.")
                        return
                    }
                    for guest in querySnapshot!.documents {
                        let name = guest.documentID
                        group.enter()
                        self.db.document("student_users/\(user.uid)/events_bought/\(nameOfEvent)/guests/\(name)").delete { (error) in
                            guard error == nil else {
                                print("The guests couldn't be deleted.")
                                return
                            }
                            print("Guests deleted with purchase refund.")
                            group.leave()
                        }
                    }
                }
                
                group.notify(queue: .main) {
                    
                    self.db.document("student_users/\(user.uid)/events_bought/\(nameOfEvent)").delete { (error) in
                        guard error == nil else {
                            print("Error trying to delete the purchased event.")
                            return
                        }
                        print("The purchased event was succesfully removed from the database!")
                    }
                    self.refundLoading.stopAnimating()
                    self.refundLoading.alpha = 0
                    self.ticketFormButton.isHidden = false
                    self.cancelPurchaseButton.isHidden = true
                    self.viewPurchaseButton.isHidden = true
                }
            }
        }
    }
    
    alertForCancel.addAction(UIAlertAction(title: "Cancel", style: .cancel))
    alertForCancel.addAction(cancelPurchase)
    present(alertForCancel, animated: true, completion: nil)
}

This actually does not work fine, yes the refund goes through on Stripe and the database is cleaned up for 3 minutes, but the print statements print in incorrect order and also the document magically reappears in the Firestore database 3 minutes after physically seeing it be deleted, how can I prevent this and make sure they print in correct order and execute in correct order to work properly on TestFlight? Is this an issue in my DispatchGroup implementation? Or is it something completely different?


Solution

  • I think it's important that no matter what you end up doing you should learn how to do everything anyway. I would advise against this approach below but it's what you originally started so let's finish it regardless, so you know how dispatch grouping works. Once you've gotten a handle on this, refine it by replacing the dispatch group with a Firestore transaction or batch operation. The point of the transaction or batch operation is so all of the documents are deleted atomically, meaning they all go or none go. This simplifies things greatly and they are very basic! And the documentation for them is very clear.

    The final thing I would suggest is perhaps integrating some recursion, meaning that if something fails it can retry automatically. Recursive functions are also very basic so just learn how to write one in Playground first and then apply it here. Just take it one step at a time and you'll get it down within a day or two. But this is the first step so carefully read what I wrote and understand why I did what I did.

    func makeRefundRequest(refundMade: @escaping (_ done: Bool) -> Void) {
        getStripePaymentIntentID { (paymentid) in
            guard let id = paymentid,
                  let url = URL(string: "https://us-central1-xxxxxx-41f12.cloudfunctions.net/createRefund") else {
                refundMade(false) // always call completion when you exit this function
                return
            }
            let json: [String: Any] = ["payment_intent": id]
    
            var request = URLRequest(url: url)
            request.httpMethod = "POST"
            request.setValue("application/json", forHTTPHeaderField: "Content-Type")
            request.httpBody = try? JSONSerialization.data(withJSONObject: json)
            
            let task = URLSession.shared.dataTask(with: request) { [weak self] (data, response, error) in
                if let response = response as? HTTPURLResponse,
                    response.statusCode == 200,
                    let data = data,
                    let _ = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] {
                    refundMade(true) // completion: true
                } else {
                    if let error = error {
                        print(error)
                    }
                    refundMade(false) // completion: false
                }
            }
    
            task.resume()
            // do not call the completion of this function here because you just made a network call
            // so call the completion of this function in that call's completion handler
        }
    }
    
    @IBAction func cancelPurchasePressed(_ sender: UIButton) {
        guard let nameOfEvent = selectedEventName,
              let user = Auth.auth().currentUser else {
                  return
        }
        let alertForCancel = UIAlertController(title: "Cancel Purchase", message: "Are you sure you want to cancel your purchase of a ticket to \(nameOfEvent)? You will receive full reimbursement of what you paid within 5 - 10 days.", preferredStyle: .alert)
    
        let cancelPurchase = UIAlertAction(title: "Cancel Purchase", style: .default) { (purchaseCancel) in
            // put the UI in a loading state
            self.viewPurchaseButton.isHidden = true
            self.cancelPurchaseButton.isHidden = true
            self.refundLoading.alpha = 1
            self.refundLoading.startAnimating()
            
            self.makeRefundRequest { (done) in
                if done {
                    self.db.collection("student_users/\(user.uid)/events_bought/\(nameOfEvent)/guests").getDocuments { (snapshot, error) in
                        guard let snapshot = snapshot,
                              !snapshot.isEmpty else {
                            if let error = error {
                                print(error)
                            }
                            return
                        }
                        let group = DispatchGroup() // instatiate the dispatch group outside the loop
    
                        for doc in snapshot.documents {
                            group.enter() // enter on each loop iteration
                            let name = doc.documentID
    
                            self.db.document("student_users/\(user.uid)/events_bought/\(nameOfEvent)/guests/\(name)").delete { (error) in
                                if let error = error {
                                    print(error)
                                }
                                group.leave() // leave no matter what the outcome, error or not
                                              // what do you do when this document didn't delete?
                                              // by doing all your deleting in a transaction or batch
                                              // you can ensure that they all delete or none delete
                            }
                        }
    
                        group.notify(queue: .main) { // done with loop, make final network call
                            self.db.document("student_users/\(user.uid)/events_bought/\(nameOfEvent)").delete { (error) in
                                if let error = error {
                                    print(error)
                                }
    
                                // put the UI back to normal state
                                self.refundLoading.stopAnimating()
                                self.refundLoading.alpha = 0
                                self.ticketFormButton.isHidden = false
                                self.cancelPurchaseButton.isHidden = true
                                self.viewPurchaseButton.isHidden = true
                            }
                        }
                    }
                } else { // refund was not made, put the UI back into normal state
                    self.refundLoading.stopAnimating()
                    self.refundLoading.alpha = 0
                    self.ticketFormButton.isHidden = false
                    self.cancelPurchaseButton.isHidden = true
                    self.viewPurchaseButton.isHidden = true
                }
            }
        }
        
        alertForCancel.addAction(UIAlertAction(title: "Cancel", style: .cancel))
        alertForCancel.addAction(cancelPurchase)
        present(alertForCancel, animated: true, completion: nil)
    }