API Rollbacks in JavaScript and Express
What is a rollback? When working in APIs a rollback would be considered a way to reimplement the state before data was changed if an error occurs.
Quick Disclaimer — *This is not a claim that this is the correct way to do this when handling requests to a database such as Google’s Firestore, MySQL or MongoDB. Most likely the middleware you use to communicate will contain a way to create a batched request, or a transaction, which will rollback for you if one of the requests fails. I however encourage people to be creative in solving any problem as a learning experience.
An example for this would be deleting data from a database. Let’s say you have a deletion endpoint which delete’s a user and all their associated data. There will be multiple calls to the database to delete this data. However, if one of these calls fails, it will put in jeopardy this process from completing if you run this in a try catch to catch errors. Most likely, some data will still be left in the database, while other calls executed successfully.
Recently in a project I was working on there was a need to run a transaction with MongoDB by creating a session to do the the scenario above. This session would technically handle a rollback for me, but I wasn’t able to get a Replica Set to work properly, which was a requirement for running transactions in the version of MongoDB the project was using. I was tasked with creating a custom rollback to resolve the issue… Opportunity to learn something new!
Here was the code needed to add a rollback:
export const deleteData = async (uid) => {
try {
await BookmarkModel.deleteMany({ uid: uid })
await StockGroupModel.deleteMany({ owner_uid: [uid] })
await ReferralCodeModel.deleteOne({ uid: uid })
await SubscriberModel.deleteOne({ uid: uid })
await UserModel.deleteOne({ uid: uid })
} catch (error) {
throw new Error(error.message)
}
}
Here there are multiple delete calls happening to different tables. The question was raised, if one of these fails what will happen? Basically some data will be deleted, some won’t, depending on where the requests fails.
To remedy this we can create a custom rollback without using a session. First we will have to create a Map to contain our rollback functionality:
const rollbacks = new Map<string, function>([
['bookmarks', null],
['stockGroups', null],
['referralCodes', null],
['subscriber', null],
['user', null],
])
This new Map will contain 2 values, one the key which is a string and the value to be set which is a function. After each successful request, we can set a function to their corresponding key to rollback the needed data.
Let’s say our first request ‘bookmarks’ succeeds, but our second request ‘stockGroups’ fails. When we hit the catch block we know which data to rollback because that key’s value won’t be null, it will be a function.
Before we assign the rollback function however, it will need all the data by first fetching it before deleting. (not ideal but does the trick!). Here we fetch the data of the originals. If the request was successful we assign a rollback function which will handle recreating all the data.
try {
// ------Bookmarks ------ //
const bookmarkOriginals = await BookmarkModel.find({ uid: sub }).lean()
await BookmarkModel.deleteMany({ uid: sub })
// Set each rollback after the deletion is successful.
//
// A rollback will not be set if it was unsuccessful, meaning the transaction failed,
// the data was not deleted.
rollbacks.set('bookmarks', async () => {
for (const bookmark of bookmarkOriginals) {
await BookmarkModel.create(bookmark)
}
})
// ------ Stock Groups ------ //
const stockGroupOriginals = await StockGroupModel.find({
owner_sub: [sub],
}).lean()
await StockGroupModel.deleteMany({ owner_sub: [sub] })
rollbacks.set('stockGroups', async () => {
for (const stockGroup of stockGroupOriginals) {
await StockGroupModel.create(stockGroup)
}
})
// If I throw an error here, it will rollback stock groups and bookmarks
throw new Error('rollback please')
// .....
//
} catch (error) {
console.error('Error during transactions, message: ', error.message)
console.error('Rollbacks started...')
// Run rollbacks
// Only runs a rollback that has been set,
// which means it was successfully deleted. We need to restore
for (const [key, value] of rollbacks) {
if (typeof value === 'function') {
console.log(`Rolling back ${key}...`)
await value()
}
}
}
This catch error will loop through the rollbacks Map and execute all successful transactions that were made to be recreated.