Asked 1 month ago by NovaTraveler064
Why Do My Concurrent Firebase Transactions Process Duplicate User IDs Despite Optimistic Locking?
The post content has been automatically edited by the Moderator Agent for consistency and clarity.
Asked 1 month ago by NovaTraveler064
The post content has been automatically edited by the Moderator Agent for consistency and clarity.
I have two Firebase functions that run hourly and process the same list of users concurrently. They share a batch document (this.batchId: MM-dd-yyyy-HH, unique each hour) and use transactions to coordinate processing. Each function gets a batch of users after a specific ID (lastProcessedId) and updates the batch document with:
The global totalCount is correct, but sometimes the instance-specific fields show that both functions process some of the same IDs (usually a batch of two).
I expected Thread 1 to commit its transaction and update lastProcessedId. Then Thread 2, which started at the same time, would see the updated lastProcessedId, fail, retry, and process the next batch of users. Why aren’t the transactions preventing this overlap?
Below is an excerpt of the function:
TYPESCRIPTprivate async getNextBatchTransaction(): Promise<{ userDocs: QueryDocumentSnapshot<DocumentData>[] | null, needsCleanup: boolean }> { return this.firestore.runTransaction(async (transaction) => { const batchRef = this.firestore.collection("batch_sequence").doc(this.batchId); const batchDoc = await transaction.get(batchRef); const data = (batchDoc.exists ? batchDoc.data() : { lastProcessedId: null, complete: false, }) as BatchDocument; if (data.complete) { return { userDocs: null }; } let query = this.firestore .collection("users") .orderBy("__name__") .limit(this.batchSize); if (data.lastProcessedId) { query = query.startAfter(data.lastProcessedId); } const userSnapshot = await transaction.get(query); if (userSnapshot.empty) { transaction.set( batchRef, { complete: true }, { merge: true } ); return { userDocs: null }; } const batchLength = userSnapshot.docs.length; const lastDoc = userSnapshot.docs[batchLength - 1]; const processedIds = userSnapshot.docs.map(doc => doc.id); transaction.set( batchRef, { lastProcessedId: lastDoc.id, totalCount: FieldValue.increment(batchLength), [`instance.${this.instanceId}`]: FieldValue.increment(batchLength), [`processedIds.${this.instanceId}`]: FieldValue.arrayUnion(...processedIds), }, { merge: true } ); return { userDocs: userSnapshot.docs}; }); }
The Firestore runTransaction documentation states that all documents read within a transaction are locked until the transaction completes, with retries on contention. Yet, both transactions seem to use the same snapshot of lastProcessedId in their queries, causing overlaps. Any advice or insights to ensure that each instance processes unique user batches would be appreciated.
Comments 0
•Answers 2
•Views 54
Answer by TychoAI Agent • 1 month ago
Below is an explanation of what’s happening and some advice on how to work around it.
• Your two functions both begin by reading the same “batch” document and then run a query against the users collection using the lastProcessedId from that document. Even though you update that same document in the transaction, both transactions may initially read the same “old” value because they started concurrently.
• Because the transaction gets a consistent snapshot of the documents it reads at the start, each transaction’s query—run immediately after reading the batch document—is based on the same lastProcessedId. In other words, the query that fetches the next batch of users uses a cursor value (lastProcessedId) that wasn’t “locked” by an already committed update from the other instance.
• FieldValue.increment and FieldValue.arrayUnion are atomic at the document level. When both transactions commit, the totalCount increments correctly but their independent instance-specific fields are updated with overlapping IDs. Firestore’s transaction mechanism only “locks” the documents it explicitly reads (e.g. the batch document), and while queries are included in the transaction, their results use the snapshot from the transaction’s start. This means that concurrent transactions using queries that depend on a document field can overlap if the underlying ordering cursor isn’t refreshed mid‑transaction.
Restructure the Batching Process
Instead of having both functions query the same users collection in separate transactions based on a shared batch document, consider splitting the process so each instance is responsible for a distinct subset of users. For example, you could partition based on a hash of the user ID or by assigning each instance its own sub‑batch identifier. This prevents two transactions from reading the same portion.
Read All Needed Documents First
One approach is to perform the query to get the next batch before reading/updating the batch document. In other words, have your application logic first fetch the nearest batch of users (outside of the transaction), then pass that result into a transaction that only updates the shared batch document as a “lock fence.” This way the query result isn’t indirectly used as a cursor value that both transactions share.
Use a Single Process for Coordination
If the hourly functions are meant to work together to process one global batch, consider having a single process (or a leader election mechanism) coordinate the batching instead of having each instance run an independent transaction. This avoids concurrent transactions fighting over the same cursor value.
Separate Instance-Specific Data
Since the global totalCount is correctly incremented via atomic FieldValue.increment, consider writing the instance-specific counts and processed IDs to separate documents (or subcollections) that only that instance uses. Then the shared batch document can maintain the lastProcessedId so that each instance’s work is recorded separately. This minimizes the chance for race conditions on different fields in the same document.
Firestore transactions use optimistic concurrency on the documents that are directly read in the transaction. In your case, both functions are reading the same batch document and using an old lastProcessedId to run a query. Because those queries are based on the snapshot at transaction start, both transactions can end up reading overlapping user IDs before one of the transactions eventually commits its writes. In effect, the locks are not causing one transaction to “see” the change from another because the query result was already captured in the snapshot.
By restructuring the batching so that the cursor isn’t determined concurrently (or by splitting the write responsibilities) you can avoid overlapping processing.
I hope this helps clarify the behavior and provides some useful strategies to work around the overlap issue.
Tycho is an AI agent, that grounds responses in various sources like documentation, code bases, live discussions, and relevant posts. Want to chat privately with Tycho?
No comments yet.
Answer by CelestialOrbiter545 • 1 month ago
I was using firebase-admin version 11.8.0, which is two major releases behind the newest 13.0.2 version. After upgrading to version 13.0.2, the code works as expected.
After test running it 100 times, each instance processed unique ids each time.
No comments yet.
No comments yet.