'How to prevent Firestore write race conditions for a reservation button

Summary

I'm developing an application where users can reserve and cancel reservations for classes. In a ReservationButtonView I two buttons that add and remove a user to a workout class respectively. Currently the button I show is based off whether the user's Firebase Auth uid is listed in a Firestore document.

I was having issues when rapidly tapping on the reservation button. Specifically, the reservationCnt would become inaccurate by showing more or less than the actual users reserved for a class.

The only way I have found to resolve this is be using a Firestore transaction that checks to see if a user is in a workout class already. If they are, addReservation() now does nothing. If they aren't, removeReservation() would also do nothing.

At first I thought I could just disable the button and via the logic still in place the code below (.disabled()), but that alone didn't work as I ran into the above described race conditions. What I found out is that arrayUnion and arrayRemove still succeed even when the object I'm looking to add is there and not there respectively. Meaning it is possible for my transaction to not remove a reservedUser that isn't there and also decrease the reservationCnt which can leave me with say no reserved users and a reservationCnt of -1

The Ask

Is there a better way to handle this reservation process? Can I accomplish this without a transaction for at least the removal of users in some way. Ideally, I'd like to have a spinner replace the button as I add or remove a user's reservation to indicate to the user that the app is processing the request. Perhaps I need two variables to manage the disabled() state instead of one?

MVVM Code Snippets

NOTE: I pulled out some button styling to make the code a bit less verbose

ReservationButtonView

struct ReservationButtonView: View {
    var workoutClass: WorkoutClass
    @ObservedObject var viewModel: WorkoutClassViewModel
    @EnvironmentObject var authViewModel: AuthViewModel
    var body: some View {
        if checkIsReserved(uid: authViewModel.user?.uid ?? "", reservedUsers: workoutClass.reservedUsers ?? []) {
            Button(action: {
                viewModel.isDisabled = true
                viewModel.removeReservation(
                    documentId: workoutClass.id!,
                    reservedUserDetails: ["uid": authViewModel.user?.uid as Any, "photoURL": authViewModel.user?.photoURL?.absoluteString ?? "" as Any, "displayName": authViewModel.user?.displayName ?? "Bruin Fitness Member" as Any],
                    uid: authViewModel.user?.uid ?? "")
            }){
                Label(
                    title: { Text("Cancel Reservation")
                        .font(.title) },
                    icon: { Image(systemName: "person.badge.minus")
                        .font(.title) }
                )
            }.disabled(viewModel.isDisabled)
        } else{
            Button(action: {
                viewModel.isDisabled = true
                viewModel.addReservation(
                    documentId: workoutClass.id!,
                    reservedUserDetails: ["uid": authViewModel.user?.uid as Any, "photoURL": authViewModel.user?.photoURL?.absoluteString ?? "" as Any, "displayName": authViewModel.user?.displayName ?? "Bruin Fitness Member" as Any],
                    uid: authViewModel.user?.uid ?? "")
            }){
                Label(
                    title: { Text("Reserve")
                        .font(.title) },
                    icon: { Image(systemName: "person.badge.plus")
                        .font(.title) }
                )
            }
            .disabled(viewModel.isDisabled)
        }
    }
}

func checkIsReserved(uid: String, reservedUsers: [reservedUser]) -> Bool {
  return reservedUsers.contains { $0.uid == uid }
}

WorkoutClassModel

struct reservedUser: Codable, Identifiable {
    var id: String = UUID().uuidString
    var uid: String
    var photoURL: URL?
    var displayName: String?
    
    enum CodingKeys: String, CodingKey {
        case uid
        case photoURL
        case displayName
    }
}


struct WorkoutClass: Codable,Identifiable {
    @DocumentID var id: String?
    var reservationCnt: Int
    var time: String
    var workoutType: String
    var reservedUsers: [reservedUser]?
    
    enum CodingKeys: String, CodingKey {
        case id
        case reservationCnt
        case time
        case workoutType
        case reservedUsers
    }
}

WorkoutClassViewModel

class WorkoutClassViewModel: ObservableObject {
    
    @Published var isDisabled = false
    private var db = Firestore.firestore()

    func addReservation(documentId: String, reservedUserDetails: [String: Any], uid: String){
        let incrementValue: Int64 = 1
        let increment = FieldValue.increment(incrementValue)
        let addUser = FieldValue.arrayUnion([reservedUserDetails])
        let classReference = db.document("schedules/Redwood City/dates/\(self.stateDate.dbDateFormat)/classes/\(documentId)")
        db.runTransaction { transaction, errorPointer in
            
            let classDocument: DocumentSnapshot
                do {
                    print("Getting classDocument for docId: \(documentId) in addReservedUser()")
                    try classDocument = transaction.getDocument(classReference)
                } catch let fetchError as NSError {
                    errorPointer?.pointee = fetchError
                    return nil
                }

            guard let workoutClass = try? classDocument.data(as: WorkoutClass.self) else {
                    let error = NSError(
                        domain: "AppErrorDomain",
                        code: -3,
                        userInfo: [
                            NSLocalizedDescriptionKey: "Unable to retrieve workoutClass from snapshot \(classDocument)"
                        ]
                    )
                    errorPointer?.pointee = error
                  return nil
                }
            
            let isReserved = self.checkIsReserved(uid: uid, reservedUsers: workoutClass.reservedUsers ?? [])
            
            if isReserved {
                print("user is already in class so therefore can't be added again")
                return nil
            } else {
                transaction.updateData(["reservationCnt": increment, "reservedUsers": addUser], forDocument: classReference)
                return nil
            }
            
        } completion: { object, error in
            if let error = error {
                print(error.localizedDescription)
                self.isDisabled = false
            } else {
                print("Successfully ran transaction with object: \(object ?? "")")
                self.isDisabled = false
            }
        }
    }
    
    func removeReservation(documentId: String, reservedUserDetails: [String: Any], uid: String){
        let decrementValue: Int64 = -1
        let decrement = FieldValue.increment(decrementValue)
        let removeUser = FieldValue.arrayRemove([reservedUserDetails])
        let classReference = db.document("schedules/Redwood City/dates/\(self.stateDate.dbDateFormat)/classes/\(documentId)")
        db.runTransaction { transaction, errorPointer in
            
            let classDocument: DocumentSnapshot
                do {
                    print("Getting classDocument for docId: \(documentId) in addReservedUser()")
                    try classDocument = transaction.getDocument(classReference)
                } catch let fetchError as NSError {
                    errorPointer?.pointee = fetchError
                    return nil
                }

            guard let workoutClass = try? classDocument.data(as: WorkoutClass.self) else {
                    let error = NSError(
                        domain: "AppErrorDomain",
                        code: -3,
                        userInfo: [
                            NSLocalizedDescriptionKey: "Unable to retrieve reservedUsers from snapshot \(classDocument)"
                        ]
                    )
                    errorPointer?.pointee = error
                  return nil
                }
            
            let isReserved = self.checkIsReserved(uid: uid, reservedUsers: workoutClass.reservedUsers ?? [] )
            
            if isReserved {
                transaction.updateData(["reservationCnt": decrement, "reservedUsers": removeUser], forDocument: classReference)
                return nil
            } else {
                print("user not in class so therefore can't be removed")
                return nil
            }
            
        } completion: { object, error in
            if let error = error {
                print(error.localizedDescription)
                self.isDisabled = false
            } else {
                print("Successfully ran removeReservation transaction with object: \(object ?? "")")
                self.isDisabled = false
            }
        }
    }
    
    func checkIsReserved(uid: String, reservedUsers: [reservedUser]) -> Bool {
      return reservedUsers.contains { $0.uid == uid }
    }
}

App screenshot

Reservation button is the green/grey button at the bottom of the view

app screenshot



Solution 1:[1]

As this is a race condition, You have already acknowledged the use of Transactions for the update which is the most desirable as this can ensure the update is successful before allowing the App to change button status. I.e. by using a transaction and only updating the UI Button state on success, which is explained here

The recommendation is to keep the state of the button mapped to what is in the document, therefore you are likely to exceed rate limits by updating the same field continuously based on the flipping of the button. Another way to handle this tracking of the state of enrollment is to add a new document that indicates the state of the enrollment for the user to a collection that is the class they are enrolling in. I.e. Rather than having the class user enrolling into being a document, make that a collection and each time the enrollment state changes, write a new document. This will allow for updates to occur without using transactions and the current state of enrollments is contained within the latest document. This latest document can be read and used as the status of the button within the App with the added benefit that the state will always update to the status contained within Firestore.

Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source
Solution 1 Divyani Yadav