Firebase cloud functions - update a different object within OnUpdate cloud trigger - firebase

Assume there is a collection of users and each user is associated with accounts, which are kept in a separate collection. For each account there is a balance which is updated periodically by some external means (e.g. the http trigger below). I need to be able to query for the user's total balance across all of her accounts.
I added onUpdate trigger which gets called everytime an account changes and updates the total accordingly. However, it seems that there is some race condition e.g. when two accounts get updated around the same time: after onUpdate is called for the first account and updates the total balance, it is still not updated when onUpdate is called for the second account. I'm guessing I need to somehow use "transaction" for the bookkeeping but not sure how.
const data = {
'users/XXX': {
email: "a#b.com",
balance: 0
},
"accounts/YYY": {
title: "Acc1",
userID: "XXX"
balance: 0
},
"accounts/ZZZ": {
title: "Acc2",
userID: "XXX"
balance: 0
}
};
exports.updateAccounts = functions.https.onRequest((request, response) => {
admin.firestore().collection('accounts').get().then((accounts) => {
accounts.forEach((account) => {
return admin.firestore().collection('accounts').doc(account.id).update({balance:
WHATEVER});
})
response.send("Done");
});
exports.updateAccount = functions.firestore
.document('accounts/{accountID}')
.onUpdate((change, context) => {
const userID = change.after.data().userID;
admin.firestore().doc("users/"+userID).get().then((user) => {
const new_balance = change.after.data().balance;
const old_balance = change.before.data().balance;
var user_balance = user.data().balance + new_balance - old_balance;
admin.firestore().doc("users/"+userID).update({balance: user_balance});
});
});

By looking at your code we can see several parts of it that could lead to incorrect results. It is not possible, without thoroughly testing and reproducing your problem, to be sure at 100% that correcting them will totally solve your problem but it is most probably the cause of the problems.
HTTP Cloud Function:
With the forEach() loop you are calling several asynchronous operations (the update() method) but you don't wait that all these asynchronous operations are completed before sending back the response. You should do as follows, using Promise.all() to wait all the asynchronous methods are completed before sending the response:
exports.updateAccounts = functions.https.onRequest((request, response) => {
const promises = [];
admin.firestore().collection('accounts').get()
.then(accounts => {
accounts.forEach((account) => {
promises.push(admin.firestore().collection('accounts').doc(account.id).update({balance: WHATEVER}));
return Promise.all(promises);
})
.then(() => {
response.send("Done");
})
.catch(error => {....});
});
onUpdate background triggered Cloud Function
There you need to correctly return the Promises chain in order to indicate to the platform when the Cloud Function is complete. The following should do the trick:
exports.updateAccount = functions.firestore
.document('accounts/{accountID}')
.onUpdate((change, context) => {
const userID = change.after.data().userID;
return admin.firestore().doc("users/"+userID).get() //Note the return here. (Note that in the HTTP Cloud Function we don't need it! see the link to the video series below)
.then(user => {
const new_balance = change.after.data().balance;
const old_balance = change.before.data().balance;
var user_balance = user.data().balance + new_balance - old_balance;
return admin.firestore().doc("users/"+userID).update({balance: user_balance}); //Note the return here.
});
});
I would suggest that you watch the 3 videos about "JavaScript Promises" from the Firebase video series: https://firebase.google.com/docs/functions/video-series/. They explain all the key points that were corrected above.
At first sight, it seems that if you modify, in the updateAccounts Cloud Function, several account documents that share the same user you will indeed need to implement the user balance update in a transaction, as several instances of the updateAccount Cloud Function may be triggered in parallel. The doc on Transactions is here.
Update:
You could implement a Transaction as follows in the updateAccounts Cloud Function (untested):
exports.updateAccount = functions.firestore
.document('accounts/{accountID}')
.onUpdate((change, context) => {
const userID = change.after.data().userID;
const userRef = admin.firestore().doc("users/" + userID);
return admin.firestore().runTransaction(transaction => {
// This code may get re-run multiple times if there are conflicts.
return transaction.get(userRef).then(userDoc => {
if (!userDoc.exists) {
throw "Document does not exist!";
}
const new_balance = change.after.data().balance;
const old_balance = change.before.data().balance;
var user_balance = userDoc.data().balance + new_balance - old_balance;
transaction.update(userRef, {balance: user_balance});
});
}).catch(error => {
console.log("Transaction failed: ", error);
return null;
});
});

In addition to what #Renaud Tarnec covered in their answer, you may also want to consider the following approaches:
Batched Write
In your updateAccounts function, you are writing many pieces of data at once, if any one of these fail, you may end up with a database that contains a mix of correctly updated data and data that had failed to be updated.
To solve this, you can use a batched write to write the data atomically where all new data is updated successfully or none of your data is written leaving your database in a known state.
exports.updateAccounts = functions.https.onRequest((request, response) => {
const db = admin.firestore();
db.collection('accounts')
.get()
.then((qsAccounts) => { // qs -> QuerySnapshot
const batch = db.batch();
qsAccounts.forEach((accountSnap) => {
batch.update(accountSnap.ref, {balance: WHATEVER});
})
return batch.commit();
})
.then(() => response.send("Done"))
.catch((err) => {
console.log("Error whilst updating balances via HTTP Request:", err);
response.status(500).send("Error: " + err.message)
});
});
Splitting the counters
Instead of storing a single "balance" in your document, it may instead be desirable (based on what you are trying to do) to store each account's balance in the user's document.
"users/someUser": {
...,
"balances": {
"accountId1": 10,
"accountId4": -20,
"accountId23": 5
}
}
If you need the cumulative balance, just add them together on the client. If you need to remove a balance, simply delete it's entry in the user document.
exports.updateAccount = functions.firestore
.document('accounts/{accountID}')
.onUpdate((change, context) => {
const db = admin.firestore();
const accountID = context.params.accountID;
const newData = change.after.data();
const accountBalance = newData.balance;
const userID = newData.userID;
return db.doc("users/"+userID)
.get()
.then((userSnap) => {
return db.doc("users/"+userID).update({["balances." + accountID]: accountBalance});
})
.then(() => console.log(`Successfully updated account #${accountID} balance for user #${userID}`))
.catch((err) => {
console.log(`Error whilst updating account #${accountID} balance for user #${userID}`, err);
throw err;
});
});

Related

Firebase listener downloads data after leaving and getting back to screen

I implemented a chatscreen inside my app and the following code represents the important sample of the code and I noticed that something about the data usage is very odd. The code is a little bit longer code sample but I will explain it after that.
const CountryChat = props =>{
var chosenLanguage = useSelector(state => state.myLanguage.myLanguage);
const countryId = props.navigation.getParam("countryId");//already upper case so no worries about correct firestore adress
const countryName = props.navigation.getParam("countryName");
const userId = useSelector(state => state.auth.userId);
const [TItext, setTItext] = useState("");
const [chatmessages, setChatMessages] = useState(() => []);//dummydata so FlatList wont crash because messages are empty during first renderprocess
const [refreshFlatlist, setRefreshFlatList] = useState(false);
const [myProfilePic, setMyProfilePic] = useState(null);
useEffect(() => {
downloadProfilePic();
var loadnewmessages = firebase.firestore().collection("group_rooms").doc("group_rooms").collection(`${countryId}`).orderBy("timestamp").limit(30).onSnapshot((snapshot) => {
var newmessages = [];
var deletedmesssages = [];
snapshot.docChanges().forEach((change) => {
if(change.type === "added"){
newmessages.push({
counter: change.doc.data().counter,
sender: change.doc.data().sender,
timestamp: change.doc.data().timestamp.toString(),
value: change.doc.data().value,
displayedTime: new Date(change.doc.data().displayedTime),
senderProfilePic: change.doc.data().senderProfilePic
})
};
if(change.type === "removed"){
deletedmesssages.push({
counter: change.doc.data().counter,
sender: change.doc.data().sender,
timestamp: change.doc.data().timestamp.toString(),
value: change.doc.data().value,
displayedTime: new Date(change.doc.data().displayedTime),
senderProfilePic: change.doc.data().senderProfilePic
})
};
})
if(newmessages.length > 0){
setChatMessages(chatmessages => {
return chatmessages.concat(newmessages)
});
};
if(deletedmesssages.length > 0){
setChatMessages(chatmessages => {
var modifythisarray = chatmessages;
let index = chatmessages.map(e => e.timestamp).indexOf(`${deletedmesssages[0].timestamp}`);
let pasttime = Date.now() - parseInt(modifythisarray[index].timestamp);
modifythisarray.splice(index, 1);
if(pasttime > 300000){
return chatmessages
}else{
return modifythisarray
}
});
setRefreshFlatList(refreshFlatlist => {
//console.log("Aktueller Status von refresher: ", refreshFlatlist);
return !refreshFlatlist
});
}
newmessages = [];
deletedmesssages = [];
});
return () => { //for removing listeners
try{
loadnewmessages();
}catch(error){console.log(error)};
}
}, []);
const pushMessagetoDB = async (filter, imageName) => {
//sending message to the chatroom in Firestore
if(filter == 1){
await firebase.firestore().collection("group_rooms").doc("group_rooms").collection(`${countryId}`).add({
"counter": 1,
"sender": userId,
"timestamp": Date.now(),
"value": TItext,
"displayedTime": (new Date()).toISOString(),
"senderProfilePic": myProfilePic
})
.then(() => {
console.log("Chat written in DB!");
})
.catch((error) => {
console.error("Error writing Chat into DB: ", error);
});
}else{
await firebase.firestore().collection("group_rooms").doc("group_rooms").collection(`${countryId}`).add({
"counter": 2,
"sender": userId,
"timestamp": Date.now(),
"senderProfilePic": myProfilePic,
"value": await firebase.storage().ref(`countrychatimages/${countryId}/${imageName}`).getDownloadURL().then((url) => {
return url
}).catch((error) => { //incase something bad happened
console.log(error);
})
})
.then(() => {
console.log("Image passed to DB!");
})
.catch((error) => {
console.error("Error passing Image to DB: ", error);
});
}
};
What you can see here is my listener loadnewmessages which is beeing called inside my useEffect. This listener downloads the recent 30 messages in the chat and stores them in a state. The chat works perfect and I can even send a message (store a document on the firestore inside a collection which represents the chat). After I leave the screen the return in the useEffect is fired and my listener is getting canceled.
My problem is: I went back and forth around 4 times and I had 6 messages in my collection. After I did that I closed my app and checked my usage in "Usage and billing" in firebase and saw that suddenly I had around 25 reads. I was expecting that my listener will only download the collection with the documents once and will maintain in on the phone even if I leave the screen, not that I redownload it always when I check the screen, that is what I assume is happening after I saw my usage in my firebase console. If I launch my app and I receive 100 or more users, my billings will explode this way.
I know that I detach my listener and relaunch it but I expected firebase to maintain the already loaded data on the phone so I (if no new files will be written) I only get 1 read because the query run without loading any new data.
Can somebody pls explain to me what I did wrong or how I could improve my code to shrink down the reads? How can I change my code so it stays efficient and does not download already loaded data? Its really important for me to maintain the reads on a low level, I have big problems getting this under control and my money is very limited.
That is the intended behavior. When you switch your pages/activities the listener is closed. A listener will fetch all the matching documents specified in query when it's reconnected (just like being connected for first time) as mentioned in the docs:
An initial call using the callback you provide creates a document snapshot immediately with the current contents of the single document. Then, each time the contents change, another call updates the document snapshot.
You can try:
Enabling offline persistence which caches a copy of the Cloud Firestore data that your app is actively using, so your app can access the data when the device is offline. If the documents are fetched from the cache then you won't be charged reads. However I am not sure if this will be the best option for your use case.
Storing messages fetched so far in local storage of that platform and then query messages sent after message using the listener. You would have to remove messages from local storage if any message is deleted.
const messagesRef = db..collection("group_rooms").doc("group_rooms").collection(`${countryId}`);
return messagesRef.doc("last_local_msg_id").get().then((doc) => {
// Get all messages sent after last local msg
const newMessagesQuery = messagesRef
.orderBy("timestamp")
.startAt(doc)
.limit(30);
});
Using for example async storage suits good, even increasing the size of the memory of async storage is not a problem so that its possible to store more data and therefore more chats as showed here.

How to await a write function inside a get function with Firebase Cloud Function for Flutter app

So, I don't really know how to write JS, I am developing a mobile app in Flutter, and I would be grateful for some help and clarifications regarding Future/Promises in JS.
I got a collection of posts for each user, and I want to create an .onCreate function which when a user posts a new post (a new document is created inside the 'posts/userId/user_posts' collection), then it gets all the user's followers (from a collection 'user_followers/userUid') and for each follower, it writes the postUid and postOwnerUid to that follower's newsFeed collection ('user_news_feed/followerId').
This is what I got right now, but I am walking blind, since I really don't know JS and I don't know how can I await a write function while inside a get function.
And how do I prevent Cloud Timeouts? If for instance the user has 1000 followers, how can I prevent Firebase from shutting down my function and making sure all the followers are notified?
exports.writeToUserNewsFeed = functions.firestore
.document('posts/{userId}/user_posts/{postId}')
.onCreate((snap, context) => {
const postData = snap.data();
const postUid = postData['post_uid'];
const userUid = postData['user_uid'];
const postCreationDate = postData['post_creation_date'];
var docRef = db.collection('user_followers').doc(userUid).collection('followers');
docRef.get().then((querySnapshot) => {
querySnapshot.forEach((doc) => {
db.collection('user_news_feed')
.doc(doc.data['uid'])
.collection('feed')
.document(postUid)
.set({
'post_uid': postUid,
'user_uid': userUid,
'post_uid': postCreationDate,
});
});
});
});
As explained in the doc, in a background Cloud Function like an onCreate() for Firestore, you need to return a Promise when all the asynchronous work is completed. So in your case, one possibility is to use Promise.all() because you don't know upfront how many documents are in the followers subcollection. Since Promise.all() returns a single Promise you can include it in the Promise chain that you need to return in the Cloud Function.
exports.writeToUserNewsFeed = functions.firestore
.document('posts/{userId}/user_posts/{postId}')
.onCreate((snap, context) => {
const postData = snap.data();
const postUid = postData['post_uid'];
const userUid = postData['user_uid'];
const postCreationDate = postData['post_creation_date'];
var followersColRef = db.collection('user_followers').doc(userUid).collection('followers');
return followersColRef.get().then((querySnapshot) => { // <= See return here
const promises = [];
querySnapshot.forEach((doc) => {
promises.push(
db.collection('user_news_feed')
.doc(doc.data['uid'])
.collection('feed')
.doc(postUid)
.set({
'post_uid': postUid,
'user_uid': userUid,
'post_uid': postCreationDate,
})
);
});
return Promise.all(promises); // <= See return here
})
.catch(error => {
console.log(error);
return null;
})
});
Note that instead of using Promise.all() you could also use a batched write but there is a limit of 500 operations for a batched write.

Trouble reading data in Firebase Cloud Function

Trying to read a pushToken from a given user in the users collection (after an update operation on another collection) returns undefined
exports.addDenuncia = functions.firestore
.document('Denuncias/{denunciaID}')
.onWrite((snap, context) => {
const doc = snap.after.data()
const classificadoId = doc.cid
const idTo = doc.peerId
db.collection('Classificados').doc(classificadoId)
.update({
aprovado: false
})
.then(r => {
getToken(idTo).then(token => {
// sendMsg...
})
}).catch(updateErr => {
console.log("updateErr: " + updateErr)
})
async function getToken(id) {
let response = "getTokenResponse"
console.log("id in getToken: " + id)
return db.collection('users').doc(id).get()
.then(user => {
console.log("user in getToken: " + user.data())
response = user.data().pushToken
})
.catch(e => {
console.log("error get userToken: " + e)
response = e
});
return response
}
return null
});
And this is from the FB console log:
-1:43:33.906 AM Function execution started
-1:43:36.799 AM Function execution took 2894 ms, finished with status: 'ok'
-1:43:43.797 AM id in getToken: Fm1RwJaVfmZoSgNEFHq4sbBgoEh1
-1:43:49.196 AM user in getToken: undefined
-1:43:49.196 AM error get userToken: TypeError: Cannot read property 'pushToken' of undefined
-1:43:49.196 AM returned token: undefined
And we can see in this screenshot from the db that the doc does exist:
Hope someone can point me to what I'm doing wrong here.
added screenshot of second example of #Renaud as deployed:
As Doug wrote in his comment, you need to "return a promise from the top level function that resolves when all the async work is complete". He also explains that very well in the official video series: https://firebase.google.com/docs/functions/video-series/ (in particular the 3 videos titled "Learn JavaScript Promises"). You should definitely watch them, highly recommended!
So, the following modifications to your code should work (untested):
exports.addDenuncia = functions.firestore
.document('Denuncias/{denunciaID}')
.onWrite(async (snap, context) => { // <- note the async keyword
try {
const doc = snap.after.data()
const classificadoId = doc.cid
const idTo = doc.peerId
await db.collection('Classificados').doc(classificadoId)
.update({
aprovado: false
});
const userToSnapshot = await db.collection('users').doc(idTo).get();
const token = userToSnapshot.data().pushToken;
await sendMsg(token); // <- Here you should take extra care to correctly deal with the asynchronous character of the sendMsg operation
return null; // <-- This return is key, in order to indicate to the Cloud Function platform that all the asynchronous work is done
} catch (error) {
console.log(error);
return null;
}
});
Since you use an async function in your code, I've used the async/await syntax but we could very well write it by chaining the promises with the then() method, as shown below.
Also, I am not sure, in your case, that it adds any value to put the code that gets the token in a function (unless you want to call it from other Cloud Functions but then you should move it out of the addDenuncia Cloud Function). That's why it has been replaced by two lines of code within the main try block.
Version with chaining promises via the then() method
In this version we chain the different promises returned by the asynchronous methods with the then() method. Compared to the async/await version above, it shows very clearly what means "to return a promise from the top level function that resolves when all the asynchronous work is complete".
exports.addDenuncia = functions.firestore
.document('Denuncias/{denunciaID}')
.onWrite((snap, context) => { // <- no more async keyword
const doc = snap.after.data()
const classificadoId = doc.cid
const idTo = doc.peerId
return db.collection('Classificados').doc(classificadoId) // <- we return a promise from the top level function
.update({
aprovado: false
})
.then(() => {
return db.collection('users').doc(idTo).get();
})
.then(userToSnapshot => {
if {!userToSnapshot.exists) {
throw new Error('No document for the idTo user');
}
const token = userToSnapshot.data().pushToken;
return sendMsg(token); // Again, here we make the assumption that sendMsg is an asynchronous function
})
.catch(error => {
console.log(error);
return null;
})
});

Firestore transaction failing inside forEach loop

I am trying to update several Firestore documents, based on the result of a third-party service inside a transaction. Problem is, I am getting the following error:
Error: Cannot modify a WriteBatch that has been committed.
Here is my code:
export default async function debitDueTransactions(context: any) {
const now = new Date().getTime();
return db.runTransaction(async (transaction: FirebaseFirestore.Transaction) => {
const chargesToCaptureRef = db.collection(`charges_to_capture`)
.where('dateToCapture', '>=', now)
.where('dateToCapture', '<=', (now + 86400000))
.where('captureResult', '==', null);
return transaction.get(chargesToCaptureRef).then((chargeToCaptureQuerySnap: FirebaseFirestore.QuerySnapshot) => {
chargeToCaptureQuerySnap.forEach(async (doc: FirebaseFirestore.QueryDocumentSnapshot) => {
const chargeToCapture = <ChargeToCapture>doc.data();
chargeToCapture.id = doc.id;
let errorKey = null;
// Calling third party service here, waiting response
const captureResult = await captureCharge(chargeToCapture.chargeId).catch((error: any) => {
errorKey = error.code ? error.code : 'unknown_error';
});
transaction.update(doc.ref, { captureResult: captureResult, errorKey: errorKey });
});
return new Promise((resolve) => { resolve(); });
})
});
}
Can't get what I am doing wrong, any idea ?
As you can see from the API documentation, transaction.get() only accepts a DocumentReference type object. You're passing it a Query object. A Firestore transaction isn't capable of transacting on a Query. If you want to transact on all the documents returned from a Query, you should perform the query before the transaction, then use transaction.get() on each DocumentReference individually.

firestore cloud functions onCreate/onDelete sometimes immediately triggered twice

I have observed this behavior occasionally with both onCreate and onDelete triggers.
Both the executions happened for the same document created in firestore. There's only one document there so I don't understand how it could trigger the handler twice. the handler itself is very simple:
module.exports = functions.firestore.document('notes/{noteId}').onCreate((event) => {
const db = admin.firestore();
const params = event.params;
const data = event.data.data();
// empty
});
this doesn't happen all the time. What am I missing?
See the Cloud Firestore Triggers Limitations and Guarantees:
Delivery of function invocations is not currently guaranteed. As the
Cloud Firestore and Cloud Functions integration improves, we plan to
guarantee "at least once" delivery. However, this may not always be
the case during beta. This may also result in multiple invocations
for a single event, so for the highest quality functions ensure that
the functions are written to be idempotent.
There is a Firecast video with tips for implementing idempotence.
Also two Google Blog posts: the first, the second.
Based on #saranpol's answer we use the below for now. We have yet to check if we actually get any duplicate event ids though.
const alreadyTriggered = eventId => {
// Firestore doesn't support forward slash in ids and the eventId often has it
const validEventId = eventId.replace('/', '')
const firestore = firebase.firestore()
return firestore.runTransaction(async transaction => {
const ref = firestore.doc(`eventIds/${validEventId}`)
const doc = await transaction.get(ref)
if (doc.exists) {
console.error(`Already triggered function for event: ${validEventId}`)
return true
} else {
transaction.set(ref, {})
return false
}
})
}
// Usage
if (await alreadyTriggered(context.eventId)) {
return
}
In my case I try to use eventId and transaction to prevent onCreate sometimes triggered twice
(you may need to save eventId in list and check if it exist if your function actually triggered often)
const functions = require('firebase-functions')
const admin = require('firebase-admin')
const db = admin.firestore()
exports = module.exports = functions.firestore.document('...').onCreate((snap, context) => {
const prize = 1000
const eventId = context.eventId
if (!eventId) {
return false
}
// increment money
const p1 = () => {
const ref = db.doc('...')
return db.runTransaction(t => {
return t.get(ref).then(doc => {
let money_total = 0
if (doc.exists) {
const eventIdLast = doc.data().event_id_last
if (eventIdLast === eventId) {
throw 'duplicated event'
}
const m0 = doc.data().money_total
if(m0 !== undefined) {
money_total = m0 + prize
}
} else {
money_total = prize
}
return t.set(ref, {
money_total: money_total,
event_id_last: eventId
}, {merge: true})
})
})
}
// will execute p2 p3 p4 if p1 success
const p2 = () => {
...
}
const p3 = () => {
...
}
const p4 = () => {
...
}
return p1().then(() => {
return Promise.all([p2(), p3(), p4()])
}).catch((error) => {
console.log(error)
})
})
Late to the party, I had this issue but having a min instance solved the issue for me
Upon looking #xaxsis attached screenshot, my function took almost the amount of time about 15 seconds for the first request and about 1/4 of that for the second request

Resources