I’m working a project that uses Firestore, so I’m using the firestore crate to help me interact with my database.

There are two examples showing the use of transactions in the source code:

There are some parts of those examples that are a little confusing, so I’m writing this article to try and shed some light.

I was not the only one confused by this, and luckily someone brought this up in a Github issue before I had to.

Transactions in Firestore

Firestore’s transactions have a lot of limitations compared with relational databases:

  • When a transaction contains reads and writes, all reads must come before any writes
  • Transactions can’t lock a whole collection
  • Transactions don’t contain locks. If a transaction fails, it will be retried a finite number of times before it fails

Writing transactional code

To write transactional code in Rust, we use the run_transaction method of FirestoreDb:

1
2
3
let tx_res = main_db.run_transaction(|db, _tx| {
    ...
}

Any code run using db inside run_transaction, will run as a single transaction.

db_transaction receives a closure as an argument. This closure must return a boxed future, so it’s usually called like this:

1
2
3
4
5
let tx_res = main_db.run_transaction(|db, _tx| {
    async move {
        ...
    }.boxed();
}

Here is an example of updating a user’s field using a transaction:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
let tx_res = main_db.run_transaction(|db, tx| {
    async move {
        // Get the user
        let found_user_opt: Option<User> = match db
            .fluent()
            .select()
            .by_id_in(COLLECTION_NAME)
            .obj()
            .one("jose")
            .await {
                Ok(f) => f,
                Err(err) => {
                    println!("Error finding user: {}", err);
                    return Ok::<bool, BackoffError<FirestoreError>>(false);
                }
            };

        if found_user_opt.is_some() {
            println!("User found");
        } else {
            println!("User not found");
            return Ok(false);
        }

        // Update a field
        let mut found_user = found_user_opt.unwrap();
        found_user.views = found_user.views + 1;

        // Write the update
        match db.fluent()
            .update()
            .in_col(COLLECTION_NAME)
            .document_id("jose".to_string())
            .object(&found_user)
            .add_to_transaction(tx) {
                Ok(_) => {},
                Err(err) => {
                    panic!("Error updating user: {}", err);
                }
            };

        return Ok(true);
    }.boxed()
}).await;

There are some things here that are not very intuitive. In line 25, we can see that add_to_transaction is used. If we had omitted it, the code wouldn’t work.

The select statement is part of the transaction, even when it doesn’t contain a call to add_to_transaction.

Insert operations don’t use add_to_transaction either, but they can use used inside a transaction:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
let tx_res = main_db.run_transaction(|db, _tx| {
    async move {
        // Get the user
        let found_user_opt: Option<User> = match db
            .fluent()
            .select()
            .by_id_in(COLLECTION_NAME)
            .obj()
            .one("jose")
            .await {
                Ok(f) => f,
                Err(err) => {
                    println!("Error finding user: {}", err);
                    return Ok::<bool, BackoffError<FirestoreError>>(false);
                }
            };

        if found_user_opt.is_some() {
            println!("User found");
        } else {
            println!("User not found");
            return Ok(false);
        }

        let carlos = User {
            username: "carlos".to_string(),
            views: 0,
        };

        match db.fluent()
            .insert()
            .into(COLLECTION_NAME)
            .document_id("carlos".to_string())
            .object(&carlos)
            .execute::<()>()
            .await {
                Ok(_) => {
                    println!("Carlos inserted");
                },
                Err(err) => {
                    panic!("Error inserting carlos: {}", err);
                }
            };

        return Ok(true);
    }.boxed()
}).await;

This by itself makes the API confusing, but there is more.

We can’t select and insert the same document in a transaction. In the following example, we try to insert a user if the username isn’t already taken:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
let tx_res = main_db.run_transaction(|db, _tx| {
    async move {
        // Get the user
        let found_user_opt: Option<User> = match db
            .fluent()
            .select()
            .by_id_in(COLLECTION_NAME)
            .obj()
            .one("jose")
            .await {
                Ok(f) => f,
                Err(err) => {
                    println!("Error finding user: {}", err);
                    return Ok::<bool, BackoffError<FirestoreError>>(false);
                }
            };

        if found_user_opt.is_some() {
            println!("User found");
            return Ok(false);
        } else {
            println!("User not found");
        }

        let jose = User {
            username: "jose".to_string(),
            views: 0,
        };

        match db.fluent()
            .insert()
            .into(COLLECTION_NAME)
            .document_id("jose".to_string())
            .object(&jose)
            .execute::<()>()
            .await {
                Ok(_) => {
                    println!("Jose inserted");
                },
                Err(err) => {
                    panic!("Error inserting jose: {}", err);
                }
            };

        return Ok(true);
    }.boxed()
}).await;

This code fails with this confusing error message:

1
Database general error occurred: Error code: Aborted. status: Aborted, message: "Transaction lock timeout.",

The error doesn’t explain anything. The reality is that selecting and then inserting a document in the same transaction is just not allowed at the time.

Conclusion

Firestore is a cheap storage option, but it comes with some challenges. Some of those are mentioned in this article.

Furthermore, the firestore crate uses an API that seems inconsistent. This is probably a reflection of the limitations of the Firestore API itself, but makes adoption a little challenging.

As usual, you can find complete versions of the code in this article in my examples’ repo.

[ databases  programming  rust  ]
Error Handling in Rust
Sending E-mails From Rust With Brevo
B-Trees - Database storage internals
Asynchronous Programming with Tokio
Programming Concurrency in Rust