Transactions
Read and write atomically with transactions
conceptual
Transactions are for operations where a preliminary read is required to determine the applicability, nature and extent of a tentative write.
A naive read and write is subject to desync: by the time the write order hits the database, the motivating condition may have changed: the data can change between the database read and the database write, as simple reads have no lock effect.
A transaction is a guarantee that by the time the database commits the write, the data hasn't changed since the initial read, so that the check that was performed on the data is still relevant.
For example, if credits is positive and sufficient, the condition is fulfilled, but by the time we are about to commit the purchase, we want credits not to have changed since the read.
implementation
The simplest way to guarantee it is to lock the document between the read and the write. The Admin SDK locks the document during the read to write time-window.
The client SDK doesn't lock the document, because the time between the read and write orders can become overly long and degrade the UX for the rest of users. Instead:
- the SDK is aware of the document's version (by the time it was read), and asks the database to only perform the write if the document is still of this version (possibly tracked with an updateTime flag or equivalent).
- The database allows changes initiated by other operations, if any.
- On receiving the conditional write order, the database enforces the condition: it proceeds only if the document hasn't changed. Otherwise, it rejects the transaction: it is up to the client SDK to attempt a new transaction. The client SDK does retry by default, up to 5 total attempts. (retry strategy).
This pattern is called emulated optimistic concurrency.
See also: Transaction serializability and isolation (Firestore).
the runTransaction function
runTransaction expects a callback. transaction is a helper that holds the read and write methods (get, update, set).
Note that we await reads, but don't await writes, since writes are grouped up on the transaction object and sent all at once.
In case of failed preconditions, we abort the transaction with a throw:
await runTransaction(db, async (transaction) => {
// read
const snapshot = await transaction.get(docRef)
// check condition
if (!snapshot.data()) throw Error("No such event!")
const count = snapshot.data().count
if (count >= 10) throw Error("Sorry, event is full!") // Abort
// proceed
transaction.update(docRef, { count: count + 1 })
})
// admin SDK
// await db.runTransaction(async (transaction) => {
// identical API
// })