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
]