Michael van de Waeter
Michael van de Waeter

Reputation: 1753

Firebase updates documents fields while expected to not do that (using transactions)

For my website I store notifications per user in a UserDocument. Each UserDocument has a collection named notifications. In this collection there are multiple NotificationDocuments.

users/<uid>/notifications/<notification_id>

NotificationDocument:
message: str
is_read: bool
generated_id: str

A notifcation could be: "You have 2 replies on your comment X"

When another reply is posted on the users' comment, the SAME NotificationDocument is updated to become:

"You have 3 replies on your comment X".

Now, the NotifcationDocument has a is_read status. By default is_read=False. However, when a user reads a notification, this NotifcationDocument is set to `is_read=True.

Then there is the race condition. What if a user wants to mark it's notification as read, but in the meanwhile another notification enters, updating the content to "You have 4 replies on your comment X". If the NotificationDocument has changed within the time that the user wants to mark it as is_read=True I want to skip the update.

So, I thought, let's use Firebase transactions: https://cloud.google.com/firestore/docs/manage-data/transactions

This is the code I have for marking a NotificationDocument as read:

    def mark_notification_as_read(
        self, notification_id: str, expected_generated_id: str, uid: str
    ) -> None:
        """
        Mark a notification with given `notification_id` as read, but only if it hasn't been updated in the meanwhile.
        This is done by checking if `generated_id` is still the same.
        """
        path =  f"users/{uid}/notifications"
        transaction = self.client.transaction()
        notification_ref = self.client.collection(path).document(notification_id)

        @firestore.transactional
        def update_in_transaction(transaction, notification_ref):
            snapshot = notification_ref.get(transaction=transaction)
            time.sleep(10)
            found_generated_id = snapshot.get('generated_id')

            if found_generated_id == expected_generated_id:
                transaction.update(notification_ref, {
                    'is_read': True,
                })
            else:
                # Log for now, to be able to monitor if this is handled well
                logger.info(msg="Can't mark notification as read as it has been updated!")

        update_in_transaction(transaction, notification_ref)

Note that within the time.sleep(10), I update the generated_id to a new value in the firebase console. I expect that this transaction should then fail. However, after those 10 seconds are passed, I see that the notification is marked as is_read=True anyways.

What am I doing wrong?

Upvotes: 0

Views: 55

Answers (1)

Michael van de Waeter
Michael van de Waeter

Reputation: 1753

After some searching, I solved my specific problem by not using transactions, but by using a write_option.

Basically, I receive the snapshot's update_time (a field given by Firebase), and when I do the update, I pass this on as a write_option. Stating: if on update the last_update is not the same, then fail the update action.

    from google.api_core.exceptions import FailedPrecondition

    [...]
        path = f"users/{uid}/notifications"
        notification_ref = self.client.collection(path).document(id)
        snapshot = notification_ref.get()

        if snapshot.exists:
            if snapshot.get("generated_id") == generated_id:
                write_option = self.client.write_option(
                    last_update_time=snapshot.update_time
                )
                try:
                    notification_ref.update({"is_read": True}, option=write_option)
                except FailedPrecondition:
                    pass
     [...]

Upvotes: 1

Related Questions